feat(secrets-cli): deployment seeding flow — audit ${secret:} refs and fill the gaps
This commit is contained in:
@@ -0,0 +1,142 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Spectre.Console;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The deployment-setup flow: audits every <c>${secret:NAME}</c> 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 <em>can</em> be seeded
|
||||||
|
/// (<see cref="ReferenceStatus.Missing"/>, <see cref="ReferenceStatus.Tombstoned"/>, or
|
||||||
|
/// <see cref="ReferenceStatus.Undecryptable"/>) — 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
|
||||||
|
/// <see cref="ReferenceStatus.Tombstoned"/> reference revives it: the store's
|
||||||
|
/// <see cref="ISecretStore.UpsertAsync"/> overwrites the row in place and clears its tombstone. Seeding an
|
||||||
|
/// <see cref="ReferenceStatus.Undecryptable"/> reference overwrites the unreadable row with a fresh value
|
||||||
|
/// (the confirm text says so). An <see cref="ReferenceStatus.InvalidName"/> reference cannot be looked up
|
||||||
|
/// or seeded, so it renders a fix-the-token hint instead of a prompt;
|
||||||
|
/// <see cref="ReferenceStatus.PresentUnverified"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReferenceAuditFlow : IInteractiveFlow
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Title => "Reference audit & seed";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool RequiresKek => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(console);
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|
||||||
|
var auditor = new ReferenceAuditor();
|
||||||
|
IReadOnlyList<ReferenceFinding> 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<ReferenceFinding> 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<string>($" Value for [green]{Markup.Escape(name.Value)}[/]:").Secret());
|
||||||
|
SecretContentType contentType = console.Prompt(
|
||||||
|
new SelectionPrompt<SecretContentType>().Title(" Content type").AddChoices(Enum.GetValues<SecretContentType>()));
|
||||||
|
string description = console.Prompt(
|
||||||
|
new TextPrompt<string>(" 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<ReferenceFinding> 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<ReferenceFinding> findings) =>
|
||||||
|
string.Join(", ", findings
|
||||||
|
.GroupBy(f => f.Status)
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Count()} {g.Key}")));
|
||||||
|
}
|
||||||
+195
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the deployment-setup <see cref="ReferenceAuditFlow"/> against a real temp-SQLite full session
|
||||||
|
/// whose <see cref="StoreTarget.Configuration"/> references three <c>${secret:NAME}</c> tokens — one
|
||||||
|
/// seeded (Ok), one absent (Missing), and one tombstoned. Each flow renders to a scripted
|
||||||
|
/// <see cref="TestConsole"/>, so the tests assert both the rendered <see cref="TestConsole.Output"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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<SecretsSession> NewSessionAsync(
|
||||||
|
IEnumerable<KeyValuePair<string, string?>>? 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<KeyValuePair<string, string?>> 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<ReferenceFinding> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user