using System.Text.Json;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Rotation;
namespace ZB.MOM.WW.Secrets.Cli;
///
/// The thin, front-end-agnostic command layer behind the secret CLI verbs
/// (set / get / list / rm / rotate). Each verb is an
/// -returning task (0 on success, non-zero on error) and writes ALL output to the
/// injected — never to directly — so the layer is
/// fully testable and can be hosted under any front-end. Confirmation and listing output is JSON
/// metadata only and NEVER carries a secret value; the sole value-exposing surface is
/// , which intentionally prints the decrypted plaintext (see its remarks).
///
public sealed class SecretCommands
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false };
private readonly ISecretStore _store;
private readonly ISecretCipher _cipher;
private readonly ISecretResolver _resolver;
private readonly TextWriter _output;
/// Creates the command set over the supplied core seams and output sink.
/// The encrypted store (upsert / delete / list).
/// The envelope cipher used to seal values on set / rotate.
/// The read seam used by get to resolve current plaintext.
/// Sink for all command output (JSON confirmations, listings, values).
public SecretCommands(ISecretStore store, ISecretCipher cipher, ISecretResolver resolver, TextWriter output)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_output = output ?? throw new ArgumentNullException(nameof(output));
}
///
/// set: seals under a fresh per-secret DEK and upserts it (create or
/// overwrite-in-place). Prints a JSON confirmation carrying only the name and action — never the
/// value.
///
/// The secret name.
/// The plaintext to seal.
/// How consumers should interpret the plaintext.
/// Optional human-readable description.
/// Principal recorded as created_by / updated_by, if known.
/// A token to cancel the operation.
/// 0 on success.
public async Task SetAsync(
string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
{
var secretName = new SecretName(name);
await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
WriteJson(new { name = secretName.Value, action = "set" });
return 0;
}
///
/// rotate: like , but REQUIRES the secret to already exist (and not be
/// tombstoned). Rotating a missing secret prints a not-found error and returns non-zero without
/// creating anything. The confirmation carries only the name and action — never the value.
///
/// The existing secret to rotate.
/// The new plaintext to seal in place.
/// How consumers should interpret the plaintext.
/// Optional human-readable description.
/// Principal recorded as updated_by, if known.
/// A token to cancel the operation.
/// 0 on success; non-zero if the secret does not exist.
public async Task RotateAsync(
string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
{
var secretName = new SecretName(name);
StoredSecret? existing = await _store.GetAsync(secretName, ct).ConfigureAwait(false);
if (existing is null || existing.IsDeleted)
{
WriteJson(new { name = secretName.Value, error = "not-found" });
return 1;
}
await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
WriteJson(new { name = secretName.Value, action = "rotate" });
return 0;
}
///
/// get: resolves and prints the current DECRYPTED plaintext for to the
/// output sink.
///
///
/// This deliberately exposes the secret value. It is the one command whose whole purpose
/// is to surface plaintext (to stdout), so an operator can pipe or capture it. Every other
/// command emits metadata only. A missing or tombstoned secret prints a JSON not-found
/// error and returns non-zero.
///
/// The secret to resolve.
/// A token to cancel the operation.
/// 0 when the value was printed; non-zero when the secret is absent.
public async Task GetAsync(string name, CancellationToken ct)
{
var secretName = new SecretName(name);
string? value = await _resolver.GetAsync(secretName, ct).ConfigureAwait(false);
if (value is null)
{
WriteJson(new { error = "not-found" });
return 1;
}
_output.WriteLine(value);
return 0;
}
///
/// list: prints a JSON array of the safe metadata projection for every secret — name,
/// description, content type, KEK id, revision, tombstone flag, and update stamps. NEVER a value.
///
/// When , tombstoned rows are included.
/// A token to cancel the operation.
/// 0 on success.
public async Task ListAsync(bool includeDeleted, CancellationToken ct)
{
IReadOnlyList items = await _store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
var projection = items.Select(m => new
{
name = m.Name.Value,
description = m.Description,
contentType = m.ContentType.ToString(),
kekId = m.KekId,
revision = m.Revision,
isDeleted = m.IsDeleted,
updatedUtc = m.UpdatedUtc,
updatedBy = m.UpdatedBy,
});
WriteJson(projection);
return 0;
}
///
/// rm: soft-deletes (tombstones) and prints a JSON confirmation with a
/// found flag.
///
/// The secret to tombstone.
/// Principal recorded as updated_by, if known.
/// A token to cancel the operation.
/// 0 if a row was tombstoned; non-zero if no such row existed.
public async Task RemoveAsync(string name, string? actor, CancellationToken ct)
{
var secretName = new SecretName(name);
bool removed = await _store.DeleteAsync(secretName, actor, ct).ConfigureAwait(false);
WriteJson(new { name = secretName.Value, action = "removed", found = removed });
return removed ? 0 : 1;
}
///
/// rewrap-all: rotates the master KEK by re-wrapping every row's DEK from
/// onto — bodies are never re-encrypted and no plaintext is exposed.
/// Prints a JSON report of the counts (total / rewrapped / already-current) and NEVER any key
/// material. Idempotent + resumable; fails closed (non-zero) on an identical-KEK mistake or a row
/// wrapped by an unrecognized KEK.
///
/// The provider for the KEK rows are currently wrapped under.
/// The provider for the KEK rows should be re-wrapped onto.
/// A token to cancel the operation.
/// 0 on success; non-zero if the rotation was rejected or aborted.
public async Task RewrapAllAsync(
IMasterKeyProvider oldKek, IMasterKeyProvider newKek, CancellationToken ct)
{
var service = new KekRotationService(_store, _cipher);
try
{
RewrapReport report = await service.RewrapAllAsync(oldKek, newKek, ct).ConfigureAwait(false);
WriteJson(new
{
action = "rewrap-all",
total = report.Total,
rewrapped = report.Rewrapped,
alreadyCurrent = report.AlreadyCurrent,
});
return 0;
}
catch (ArgumentException ex)
{
// Identical old/new KEK ids — nothing to rotate.
WriteJson(new { action = "rewrap-all", error = ex.Message });
return 1;
}
catch (SecretDecryptionException ex)
{
// A row on an unrecognized KEK aborted the pass, or a DEK failed to unwrap.
WriteJson(new { action = "rewrap-all", error = ex.Message });
return 1;
}
}
/// Seals a value under the cipher and upserts it, carrying description + actor stamps.
private async Task UpsertSealedAsync(
SecretName name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
{
StoredSecret row = _cipher.Encrypt(name, value, contentType) with
{
Description = description,
CreatedBy = actor,
UpdatedBy = actor,
};
await _store.UpsertAsync(row, ct).ConfigureAwait(false);
}
/// Serializes to compact JSON on its own line.
private void WriteJson(object value) => _output.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
}