From 779f8a8d7847c57ade8e839f746fdcefc3b505eb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Jul 2026 09:43:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(secrets-cli):=20deployment=20seeding=20flo?= =?UTF-8?q?w=20=E2=80=94=20audit=20${secret:}=20refs=20and=20fill=20the=20?= =?UTF-8?q?gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interactive/Flows/ReferenceAuditFlow.cs | 142 +++++++++++++ .../Flows/ReferenceAuditFlowTests.cs | 195 ++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs new file mode 100644 index 0000000..b26d0ab --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs @@ -0,0 +1,142 @@ +using System.Globalization; +using Spectre.Console; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows; + +/// +/// The deployment-setup flow: audits every ${secret:NAME} reference in the target app's composed +/// configuration, renders a colour-coded status table, then walks the operator through seeding each gap so +/// the app can start. For every reference that is not already resolvable — and that can be seeded +/// (, , or +/// ) — the operator is offered a masked value prompt and the +/// new secret is sealed under a fresh per-secret DEK via the session cipher. Seeding a +/// reference revives it: the store's +/// overwrites the row in place and clears its tombstone. Seeding an +/// reference overwrites the unreadable row with a fresh value +/// (the confirm text says so). An reference cannot be looked up +/// or seeded, so it renders a fix-the-token hint instead of a prompt; +/// only occurs on a degraded session and is unreachable +/// here (the flow requires a KEK). A closing re-audit prints a one-line count-by-status summary. Requires a +/// KEK-capable session (the shell upgrades a degraded session before dispatch); typed values are masked and +/// never echoed. +/// +public sealed class ReferenceAuditFlow : IInteractiveFlow +{ + /// + public string Title => "Reference audit & seed"; + + /// + public bool RequiresKek => true; + + /// + public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(console); + ArgumentNullException.ThrowIfNull(session); + + var auditor = new ReferenceAuditor(); + IReadOnlyList findings = await auditor.AuditAsync(session, ct).ConfigureAwait(false); + if (findings.Count == 0) + { + console.MarkupLine("[grey]No ${secret:} references found in the target configuration.[/]"); + return; + } + + RenderTable(console, findings); + + foreach (ReferenceFinding finding in findings) + { + if (finding.Status == ReferenceStatus.InvalidName) + { + console.MarkupLineInterpolated( + $"[red]'{finding.SecretName}'[/] is not a valid secret name — fix the ${{secret:…}} token in the config; it cannot be seeded."); + continue; + } + + if (!IsSeedable(finding.Status)) + { + continue; // Ok (nothing to do) or PresentUnverified (unreachable on a KEK-capable session). + } + + string escapedName = Markup.Escape(finding.SecretName); + string question = finding.Status == ReferenceStatus.Undecryptable + ? $"'{escapedName}' exists but does not decrypt — overwrite with a fresh value now?" + : $"Seed '{escapedName}' now?"; + + if (!console.Prompt(new ConfirmationPrompt(question) + { + DefaultValue = finding.Status != ReferenceStatus.Undecryptable, + })) + { + continue; + } + + await SeedAsync(console, session, new SecretName(finding.SecretName), ct).ConfigureAwait(false); + console.MarkupLineInterpolated($"[green]Sealed '{finding.SecretName}'.[/]"); + } + + IReadOnlyList after = await auditor.AuditAsync(session, ct).ConfigureAwait(false); + console.MarkupLineInterpolated($"Audit complete: {Summarize(after)}."); + } + + // Seedable ⇔ a valid, non-resolvable reference we can fill: absent, tombstoned, or present-but-unreadable. + // Ok needs nothing; InvalidName cannot be looked up; PresentUnverified only occurs on a degraded session. + private static bool IsSeedable(ReferenceStatus status) => + status is ReferenceStatus.Missing or ReferenceStatus.Tombstoned or ReferenceStatus.Undecryptable; + + // Prompts for a masked value + content type + optional description and seals the secret under a fresh + // per-secret DEK. Mirrors SetSecretFlow's seal-and-stamp shape without coupling to it. + private static async Task SeedAsync( + IAnsiConsole console, SecretsSession session, SecretName name, CancellationToken ct) + { + string value = console.Prompt( + new TextPrompt($" Value for [green]{Markup.Escape(name.Value)}[/]:").Secret()); + SecretContentType contentType = console.Prompt( + new SelectionPrompt().Title(" Content type").AddChoices(Enum.GetValues())); + string description = console.Prompt( + new TextPrompt(" Description [grey](optional)[/]:").AllowEmpty()); + + string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName; + StoredSecret row = session.Cipher!.Encrypt(name, value, contentType) with + { + Description = string.IsNullOrWhiteSpace(description) ? null : description, + CreatedBy = actor, + UpdatedBy = actor, + }; + await session.Store.UpsertAsync(row, ct).ConfigureAwait(false); // revives a tombstone: overwrite clears IsDeleted. + } + + // A colour-coded audit table: green Ok, yellow PresentUnverified, red for every unresolvable status. + private static void RenderTable(IAnsiConsole console, IReadOnlyList findings) + { + var table = new Table() + .AddColumn("Secret") + .AddColumn("Status") + .AddColumn("Config paths"); + + foreach (ReferenceFinding finding in findings) + { + table.AddRow( + Markup.Escape(finding.SecretName), + $"[{StatusColor(finding.Status)}]{finding.Status}[/]", + Markup.Escape(string.Join(Environment.NewLine, finding.ConfigPaths))); + } + + console.Write(table); + } + + private static string StatusColor(ReferenceStatus status) => status switch + { + ReferenceStatus.Ok => "green", + ReferenceStatus.PresentUnverified => "yellow", + _ => "red", // Missing / Tombstoned / Undecryptable / InvalidName + }; + + // A deterministic "N Status" count-by-status roll-up, ordered by the status enum. + private static string Summarize(IReadOnlyList findings) => + string.Join(", ", findings + .GroupBy(f => f.Status) + .OrderBy(g => g.Key) + .Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Count()} {g.Key}"))); +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs new file mode 100644 index 0000000..2217abc --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs @@ -0,0 +1,195 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; +using Spectre.Console; +using Spectre.Console.Testing; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Cli.Interactive; +using ZB.MOM.WW.Secrets.Cli.Interactive.Flows; +using ZB.MOM.WW.Secrets.MasterKey; + +namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows; + +/// +/// Drives the deployment-setup against a real temp-SQLite full session +/// whose references three ${secret:NAME} tokens — one +/// seeded (Ok), one absent (Missing), and one tombstoned. Each flow renders to a scripted +/// , so the tests assert both the rendered and +/// the resulting store state. The invariant under test is that the operator can fill every startup gap +/// from one screen without any typed secret value ever reaching the terminal. +/// +public sealed class ReferenceAuditFlowTests : IDisposable +{ + private readonly string _dir = + Path.Combine(Path.GetTempPath(), $"zb-secrets-refaudit-{Guid.NewGuid():N}"); + + private string DbPath => Path.Combine(_dir, "secrets.db"); + + public ReferenceAuditFlowTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + if (Directory.Exists(_dir)) + { + try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ } + } + } + + private static TestConsole NewConsole() + { + var console = (TestConsole)new TestConsole().Interactive(); + console.Profile.Width = 240; // wide enough that the status table never truncates a cell + return console; + } + + // A full (KEK-capable) session on a fresh temp SQLite store, with an optional in-memory configuration + // layered onto the target so the reference auditor sees ${secret:...} tokens. Keyed by an operator- + // override KEK so the test never depends on ambient environment state. + private async Task NewSessionAsync( + IEnumerable>? configEntries = null) + { + byte[] key = new byte[32]; + RandomNumberGenerator.Fill(key); + var kek = new LiteralMasterKeyProvider(Convert.ToBase64String(key)); + + StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions + { + Source = MasterKeySource.Environment, + EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}", + }); + + if (configEntries is not null) + { + IConfigurationRoot config = new ConfigurationBuilder().AddInMemoryCollection(configEntries).Build(); + target = target with { Configuration = config }; + } + + return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None); + } + + // The canonical three-reference configuration: svc/ok (seeded), svc/missing (absent), svc/tomb + // (tombstoned). Ordinal name order is svc/missing < svc/ok < svc/tomb, so the non-Ok findings the + // flow prompts for arrive in the order missing, then tomb. + private static IEnumerable> ThreeReferenceConfig() => + [ + new("ConnectionStrings:Ok", "${secret:svc/ok}"), + new("Api:MissingKey", "${secret:svc/missing}"), + new("Feature:TombValue", "${secret:svc/tomb}"), + ]; + + private static async Task SeedAsync(SecretsSession session, string name, string value) + { + var secretName = new SecretName(name); + StoredSecret row = session.Cipher!.Encrypt(secretName, value, SecretContentType.Text); + await session.Store.UpsertAsync(row, CancellationToken.None); + } + + // Seeds svc/ok live and leaves svc/missing absent, then seeds + tombstones svc/tomb. + private static async Task SeedThreeReferenceStateAsync(SecretsSession session) + { + await SeedAsync(session, "svc/ok", "OK-SEEDED-VALUE"); + await SeedAsync(session, "svc/tomb", "TOMB-ORIGINAL-VALUE"); + await session.Store.DeleteAsync(new SecretName("svc/tomb"), "test", CancellationToken.None); + } + + private static int CountOccurrences(string haystack, string needle) + { + int count = 0; + for (int i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0; + i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } + + [Fact] + public async Task Renders_status_table_for_all_references() + { + SecretsSession session = await NewSessionAsync(ThreeReferenceConfig()); + await SeedThreeReferenceStateAsync(session); + + TestConsole console = NewConsole(); + console.Input.PushTextWithEnter("n"); // svc/missing: decline (this test only asserts the rendered table) + console.Input.PushTextWithEnter("n"); // svc/tomb: decline + await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); + + string output = console.Output; + Assert.Contains("svc/ok", output, StringComparison.Ordinal); + Assert.Contains("svc/missing", output, StringComparison.Ordinal); + Assert.Contains("svc/tomb", output, StringComparison.Ordinal); + Assert.Contains("Missing", output, StringComparison.Ordinal); + Assert.Contains("Tombstoned", output, StringComparison.Ordinal); + Assert.Contains("ConnectionStrings:Ok", output, StringComparison.Ordinal); + Assert.Contains("Api:MissingKey", output, StringComparison.Ordinal); + Assert.Contains("Feature:TombValue", output, StringComparison.Ordinal); + + Assert.True(new ReferenceAuditFlow().RequiresKek); + } + + [Fact] + public async Task Offers_to_seed_each_non_ok_reference_and_seeds_accepted_ones() + { + SecretsSession session = await NewSessionAsync(ThreeReferenceConfig()); + await SeedThreeReferenceStateAsync(session); + + TestConsole console = NewConsole(); + // svc/missing (first non-Ok): accept, value, Text (Enter), empty description. + console.Input.PushTextWithEnter("y"); + console.Input.PushTextWithEnter("MISSING-SEED-SENTINEL"); + console.Input.PushKey(ConsoleKey.Enter); + console.Input.PushKey(ConsoleKey.Enter); + // svc/tomb (second non-Ok): accept, value, Text (Enter), empty description. + console.Input.PushTextWithEnter("y"); + console.Input.PushTextWithEnter("TOMB-SEED-SENTINEL"); + console.Input.PushKey(ConsoleKey.Enter); + console.Input.PushKey(ConsoleKey.Enter); + + await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); + + IReadOnlyList after = + await new ReferenceAuditor().AuditAsync(session, CancellationToken.None); + Assert.All(after, f => Assert.Equal(ReferenceStatus.Ok, f.Status)); + + // The store now decrypts both freshly-seeded values. + StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None); + StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None); + Assert.Equal("MISSING-SEED-SENTINEL", session.Cipher!.Decrypt(missing!)); + Assert.Equal("TOMB-SEED-SENTINEL", session.Cipher!.Decrypt(tomb!)); + Assert.False(tomb!.IsDeleted); // upsert revived the tombstone + + Assert.DoesNotContain("MISSING-SEED-SENTINEL", console.Output, StringComparison.Ordinal); + Assert.DoesNotContain("TOMB-SEED-SENTINEL", console.Output, StringComparison.Ordinal); + } + + [Fact] + public async Task Skipped_references_are_left_untouched() + { + SecretsSession session = await NewSessionAsync(ThreeReferenceConfig()); + await SeedThreeReferenceStateAsync(session); + + TestConsole console = NewConsole(); + console.Input.PushTextWithEnter("n"); // svc/missing: decline + console.Input.PushTextWithEnter("n"); // svc/tomb: decline + + await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); + + StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None); + StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None); + Assert.Null(missing); + Assert.NotNull(tomb); + Assert.True(tomb!.IsDeleted); + } + + [Fact] + public async Task Manual_target_without_appsettings_reports_nothing_to_audit() + { + SecretsSession session = await NewSessionAsync(); // Manual target: empty composed configuration + + TestConsole console = NewConsole(); + await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None); + + Assert.Contains("No ${secret:} references found", console.Output, StringComparison.Ordinal); + Assert.Equal(0, CountOccurrences(console.Output, "Seed '")); // no seed prompt was rendered + } +}