feat(secrets-cli): CRUD flows for the interactive console
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
using Spectre.Console;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes (tombstones) a secret after an explicit confirmation. Tombstoning only rewrites metadata
|
||||
/// columns — it never touches ciphertext — so it needs no KEK and runs on a degraded session. A declined
|
||||
/// confirmation leaves the row untouched; a confirmed delete reports whether a matching row was found.
|
||||
/// </summary>
|
||||
public sealed class DeleteSecretFlow : IInteractiveFlow
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Title => "Delete a secret";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RequiresKek => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(console);
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
var name = new SecretName(console.Prompt(
|
||||
new TextPrompt<string>("Secret [green]name[/]:").Validate(NameValidation)));
|
||||
|
||||
if (!console.Prompt(new ConfirmationPrompt($"Delete (tombstone) '{name.Value}'?") { DefaultValue = false }))
|
||||
{
|
||||
console.MarkupLine("[yellow]Left in place.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
|
||||
bool removed = await session.Store.DeleteAsync(name, actor, ct).ConfigureAwait(false);
|
||||
|
||||
console.MarkupLine(removed
|
||||
? $"[green]Tombstoned '{Markup.Escape(name.Value)}'.[/]"
|
||||
: $"[red]No secret named '{Markup.Escape(name.Value)}'.[/]");
|
||||
}
|
||||
|
||||
// Re-prompts on an invalid name by surfacing the SecretName constructor's own diagnostic.
|
||||
private static ValidationResult NameValidation(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new SecretName(raw);
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return ValidationResult.Error(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Spectre.Console;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||
|
||||
/// <summary>
|
||||
/// Lists the safe metadata projection of every live secret as a table — name, content type, KEK id,
|
||||
/// revision, last-updated stamp, and updater. It renders only <see cref="SecretMetadata"/> (which carries
|
||||
/// no ciphertext or plaintext), needs no KEK, and never decrypts, so it is the one CRUD flow that runs on
|
||||
/// a degraded session.
|
||||
/// </summary>
|
||||
public sealed class ListSecretsFlow : IInteractiveFlow
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Title => "List secrets";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RequiresKek => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(console);
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
IReadOnlyList<SecretMetadata> items =
|
||||
await session.Store.ListAsync(includeDeleted: false, ct).ConfigureAwait(false);
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
console.MarkupLine("[grey](none)[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
var table = new Table()
|
||||
.AddColumn("Name")
|
||||
.AddColumn("ContentType")
|
||||
.AddColumn("KekId")
|
||||
.AddColumn("Revision")
|
||||
.AddColumn("Updated")
|
||||
.AddColumn("UpdatedBy");
|
||||
|
||||
foreach (SecretMetadata m in items)
|
||||
{
|
||||
table.AddRow(
|
||||
Markup.Escape(m.Name.Value),
|
||||
Markup.Escape(m.ContentType.ToString()),
|
||||
Markup.Escape(m.KekId),
|
||||
m.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
Markup.Escape(m.UpdatedUtc.ToString("u", System.Globalization.CultureInfo.InvariantCulture)),
|
||||
Markup.Escape(m.UpdatedBy ?? "—"));
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Spectre.Console;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||
|
||||
/// <summary>
|
||||
/// Reveals a secret's decrypted plaintext on screen — the one flow whose purpose is to surface a value.
|
||||
/// It gates the disclosure behind an explicit confirmation: on decline nothing is printed; on accept the
|
||||
/// value is decrypted with the session cipher and written exactly once (no caching, no resolver). Requires
|
||||
/// a KEK-capable session.
|
||||
/// </summary>
|
||||
public sealed class RevealSecretFlow : IInteractiveFlow
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Title => "Get (reveal) a secret";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RequiresKek => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(console);
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
var name = new SecretName(console.Prompt(
|
||||
new TextPrompt<string>("Secret [green]name[/]:").Validate(NameValidation)));
|
||||
|
||||
StoredSecret? row = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
|
||||
if (row is null || row.IsDeleted)
|
||||
{
|
||||
console.MarkupLineInterpolated($"[red]No secret named '{name.Value}'.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!console.Prompt(new ConfirmationPrompt("Reveal the value on screen?") { DefaultValue = false }))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
console.WriteLine(session.Cipher!.Decrypt(row));
|
||||
}
|
||||
|
||||
// Re-prompts on an invalid name by surfacing the SecretName constructor's own diagnostic.
|
||||
private static ValidationResult NameValidation(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new SecretName(raw);
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return ValidationResult.Error(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Spectre.Console;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
|
||||
|
||||
/// <summary>
|
||||
/// Creates or rotates a secret: prompts for a validated name, confirms before overwriting an existing
|
||||
/// live row, reads the value through a masked prompt, and seals it under a fresh per-secret DEK via the
|
||||
/// session cipher. The value is never echoed and the confirmation line carries the name only. Requires a
|
||||
/// KEK-capable session (the shell upgrades a degraded session before dispatch).
|
||||
/// </summary>
|
||||
public sealed class SetSecretFlow : IInteractiveFlow
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Title => "Set / rotate a secret";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool RequiresKek => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(console);
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
var name = new SecretName(console.Prompt(
|
||||
new TextPrompt<string>("Secret [green]name[/]:").Validate(NameValidation)));
|
||||
|
||||
StoredSecret? existing = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
|
||||
if (existing is { IsDeleted: false } &&
|
||||
!console.Prompt(new ConfirmationPrompt($"'{name.Value}' exists — overwrite in place?") { DefaultValue = false }))
|
||||
{
|
||||
console.MarkupLine("[yellow]Left unchanged.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
string value = console.Prompt(new TextPrompt<string>("Secret [green]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);
|
||||
|
||||
console.MarkupLineInterpolated($"[green]Sealed '{name.Value}'.[/]");
|
||||
}
|
||||
|
||||
// Re-prompts on an invalid name by surfacing the SecretName constructor's own diagnostic.
|
||||
private static ValidationResult NameValidation(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new SecretName(raw);
|
||||
return ValidationResult.Success();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return ValidationResult.Error(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user