diff --git a/ZB.MOM.WW.Secrets/Directory.Packages.props b/ZB.MOM.WW.Secrets/Directory.Packages.props index 84dca02..e8fbd27 100644 --- a/ZB.MOM.WW.Secrets/Directory.Packages.props +++ b/ZB.MOM.WW.Secrets/Directory.Packages.props @@ -22,6 +22,7 @@ + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs index 44ac559..465639d 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Program.cs @@ -1,3 +1,152 @@ -// Placeholder entry point for the ZB.MOM.WW.Secrets CLI. Real commands are added -// in a later task; for now it is a no-op that exits successfully. -return 0; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Cli; +using ZB.MOM.WW.Secrets.DependencyInjection; +using ZB.MOM.WW.Secrets.Sqlite; + +// Headless `secret` CLI: set / get / list / rm / rotate over the ZB.MOM.WW secrets store. +// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema +// migration, parses the verb + positional args, and dispatches. + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Configuration + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables(); +builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); + +using IHost host = builder.Build(); + +// Ensure the schema exists before any store operation. +await host.Services.GetRequiredService() + .MigrateAsync(CancellationToken.None); + +var commands = new SecretCommands( + host.Services.GetRequiredService(), + host.Services.GetRequiredService(), + host.Services.GetRequiredService(), + Console.Out); + +string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName; +CancellationToken ct = CancellationToken.None; + +if (args.Length == 0) +{ + return Usage(); +} + +try +{ + switch (args[0]) + { + case "set": + { + if (args.Length < 3) + return Usage("set requires and ."); + (SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3); + return await commands.SetAsync(args[1], args[2], contentType, description, actor, ct); + } + + case "rotate": + { + if (args.Length < 3) + return Usage("rotate requires and ."); + (SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3); + return await commands.RotateAsync(args[1], args[2], contentType, description, actor, ct); + } + + case "get": + { + if (args.Length < 2) + return Usage("get requires ."); + return await commands.GetAsync(args[1], ct); + } + + case "list": + { + bool includeDeleted = args.Skip(1).Any(a => + string.Equals(a, "--include-deleted", StringComparison.Ordinal)); + return await commands.ListAsync(includeDeleted, ct); + } + + case "rm": + { + if (args.Length < 2) + return Usage("rm requires ."); + return await commands.RemoveAsync(args[1], actor, ct); + } + + default: + return Usage($"Unknown command '{args[0]}'."); + } +} +catch (ArgumentException ex) +{ + // Invalid secret name, bad --content-type value, unknown option: report without a stack trace. + Console.Error.WriteLine(ex.Message); + return 2; +} + +// --- helpers --------------------------------------------------------------- + +// Parses the optional flags shared by `set` and `rotate`: +// [--content-type text|connection-string|json|binary-base64] [--description "..."] +static (SecretContentType contentType, string? description) ParseSetOptions(string[] args, int startIndex) +{ + SecretContentType contentType = SecretContentType.Text; + string? description = null; + + for (int i = startIndex; i < args.Length; i++) + { + switch (args[i]) + { + case "--content-type": + if (i + 1 >= args.Length) + throw new ArgumentException("--content-type requires a value."); + contentType = ParseContentType(args[++i]); + break; + + case "--description": + if (i + 1 >= args.Length) + throw new ArgumentException("--description requires a value."); + description = args[++i]; + break; + + default: + throw new ArgumentException($"Unknown option '{args[i]}'."); + } + } + + return (contentType, description); +} + +// Maps the CLI content-type token (kebab-case) to the enum. +static SecretContentType ParseContentType(string value) => value switch +{ + "text" => SecretContentType.Text, + "connection-string" => SecretContentType.ConnectionString, + "json" => SecretContentType.Json, + "binary-base64" => SecretContentType.BinaryBase64, + _ => throw new ArgumentException( + $"Unknown --content-type '{value}'; expected text|connection-string|json|binary-base64."), +}; + +// Prints usage (optionally preceded by an error line) and returns the standard usage exit code. +int Usage(string? error = null) +{ + if (error is not null) + Console.Error.WriteLine(error); + + Console.Error.WriteLine( + """ + Usage: secret [args] + + set [--content-type text|connection-string|json|binary-base64] [--description "..."] + rotate [--content-type ...] [--description "..."] + get + list [--include-deleted] + rm + """); + return 2; +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs new file mode 100644 index 0000000..5a6ede4 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs @@ -0,0 +1,174 @@ +using System.Text.Json; +using ZB.MOM.WW.Secrets.Abstractions; + +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; + } + + /// 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)); +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj index 063ae94..3302fcc 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj @@ -8,8 +8,18 @@ false + + + + + + + PreserveNewest + + + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json new file mode 100644 index 0000000..8f8dcf5 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/appsettings.json @@ -0,0 +1,10 @@ +{ + "Secrets": { + "SqlitePath": "secrets.db", + "MasterKey": { + "Source": "Environment", + "EnvVarName": "ZB_SECRETS_MASTER_KEY" + }, + "RunMigrationsOnStartup": true + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs new file mode 100644 index 0000000..5285464 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs @@ -0,0 +1,156 @@ +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Cli; +using ZB.MOM.WW.Secrets.Crypto; +using ZB.MOM.WW.Secrets.Sqlite; +using ZB.MOM.WW.Secrets.Tests.Fakes; + +namespace ZB.MOM.WW.Secrets.Tests.Cli; + +/// +/// Exercises the headless layer over a real migrated SQLite store, +/// the real envelope cipher, and the real resolver — asserting round-trips, exit codes, and the +/// hard invariant that list/set output never carries a plaintext value. +/// +public sealed class SecretCommandsTests : IAsyncLifetime, IDisposable +{ + private readonly string _dbPath = + Path.Combine(Path.GetTempPath(), $"zb-secrets-cli-{Guid.NewGuid():N}.db"); + + private readonly SecretsSqliteConnectionFactory _factory; + private readonly SqliteSecretStore _store; + private readonly AesGcmEnvelopeCipher _cipher; + private readonly DefaultSecretResolver _resolver; + + public SecretCommandsTests() + { + _factory = new SecretsSqliteConnectionFactory(_dbPath); + _store = new SqliteSecretStore(_factory); + _cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("kek-cli")); + // A near-zero TTL keeps the resolver from serving a stale plaintext across a rotate/remove. + _resolver = new DefaultSecretResolver( + _store, _cipher, NoOpAuditWriter.Instance, TimeSpan.Zero); + } + + public async Task InitializeAsync() => + await new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None); + + public Task DisposeAsync() => Task.CompletedTask; + + public void Dispose() + { + if (File.Exists(_dbPath)) + { + try { File.Delete(_dbPath); } catch (IOException) { /* best-effort temp cleanup */ } + } + } + + private (SecretCommands cmd, StringWriter output) NewSut() + { + var output = new StringWriter(); + return (new SecretCommands(_store, _cipher, _resolver, output), output); + } + + private static CancellationToken Ct => CancellationToken.None; + + [Fact] + public async Task Set_Then_Get_RoundTrips() + { + var (cmd, setOut) = NewSut(); + + int setRc = await cmd.SetAsync( + "sql/db-conn", "hunter2", SecretContentType.ConnectionString, "prod db", "alice", Ct); + + Assert.Equal(0, setRc); + // set confirmation must NOT leak the value. + Assert.DoesNotContain("hunter2", setOut.ToString()); + Assert.Contains("\"action\":\"set\"", setOut.ToString()); + Assert.Contains("sql/db-conn", setOut.ToString()); + + // A fresh writer for get so we can assert exactly the plaintext came back. + var getOut = new StringWriter(); + var getCmd = new SecretCommands(_store, _cipher, _resolver, getOut); + int getRc = await getCmd.GetAsync("sql/db-conn", Ct); + + Assert.Equal(0, getRc); + Assert.Equal("hunter2", getOut.ToString().TrimEnd('\r', '\n')); + } + + [Fact] + public async Task List_PrintsMetadata_NoValues() + { + var (cmd, _) = NewSut(); + await cmd.SetAsync("app/one", "value-one", SecretContentType.Text, null, "alice", Ct); + await cmd.SetAsync("app/two", "value-two", SecretContentType.Json, "second", "alice", Ct); + + var listOut = new StringWriter(); + var listCmd = new SecretCommands(_store, _cipher, _resolver, listOut); + int rc = await listCmd.ListAsync(includeDeleted: false, Ct); + + string json = listOut.ToString(); + Assert.Equal(0, rc); + Assert.Contains("app/one", json); + Assert.Contains("app/two", json); + Assert.DoesNotContain("value-one", json); + Assert.DoesNotContain("value-two", json); + } + + [Fact] + public async Task Rm_Tombstones() + { + var (cmd, _) = NewSut(); + await cmd.SetAsync("app/gone", "bye", SecretContentType.Text, null, "alice", Ct); + + var rm1Out = new StringWriter(); + int rc1 = await new SecretCommands(_store, _cipher, _resolver, rm1Out).RemoveAsync("app/gone", "alice", Ct); + Assert.Equal(0, rc1); + Assert.Contains("\"found\":true", rm1Out.ToString()); + + // Second remove: already tombstoned → not found. + var rm2Out = new StringWriter(); + int rc2 = await new SecretCommands(_store, _cipher, _resolver, rm2Out).RemoveAsync("app/gone", "alice", Ct); + Assert.NotEqual(0, rc2); + Assert.Contains("\"found\":false", rm2Out.ToString()); + + // Get after removal → not-found. + var getOut = new StringWriter(); + int getRc = await new SecretCommands(_store, _cipher, _resolver, getOut).GetAsync("app/gone", Ct); + Assert.NotEqual(0, getRc); + Assert.Contains("not-found", getOut.ToString()); + } + + [Fact] + public async Task Rotate_RequiresExisting_OverwritesInPlace() + { + // Rotate on a missing secret → non-zero, nothing created. + var missingOut = new StringWriter(); + int missingRc = await new SecretCommands(_store, _cipher, _resolver, missingOut) + .RotateAsync("app/rot", "v1", SecretContentType.Text, null, "alice", Ct); + Assert.NotEqual(0, missingRc); + + // After a set, rotate to a new value → 0, and get returns the new value. + await new SecretCommands(_store, _cipher, _resolver, new StringWriter()) + .SetAsync("app/rot", "v1", SecretContentType.Text, null, "alice", Ct); + + var rotateOut = new StringWriter(); + int rotateRc = await new SecretCommands(_store, _cipher, _resolver, rotateOut) + .RotateAsync("app/rot", "v2", SecretContentType.Text, null, "bob", Ct); + Assert.Equal(0, rotateRc); + Assert.Contains("\"action\":\"rotate\"", rotateOut.ToString()); + Assert.DoesNotContain("v2", rotateOut.ToString()); + + var getOut = new StringWriter(); + await new SecretCommands(_store, _cipher, _resolver, getOut).GetAsync("app/rot", Ct); + Assert.Equal("v2", getOut.ToString().TrimEnd('\r', '\n')); + } + + [Fact] + public async Task Get_MissingSecret_ReturnsNonZero() + { + var (cmd, output) = NewSut(); + int rc = await cmd.GetAsync("nope/missing", Ct); + + Assert.NotEqual(0, rc); + Assert.Contains("not-found", output.ToString()); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj index e96d479..5094891 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj @@ -21,6 +21,7 @@ +