feat(secrets): Grpc hub client — pull-only sweep service + options + DI
Client half of the central-hosted secrets hub: a site node follows central and never pushes. - `ISecretsHubReader` + `GrpcSecretsHubClient` — the read half of the hub as a follower sees it (manifest + bulk fetch, nothing else). Bearer credential and a per-call deadline are attached per call; responses convert through `WireToDomain`. Faults are deliberately not caught here, so an unreachable hub can never be mistaken for an empty one. - `WireToDomain` — strict, per-row proto -> domain parse mirroring the Akka replicator's `SecretRowDto`: a name the domain would reject, a content type naming no enum member, or a non-round-trip timestamp is refused at the boundary. Explicit proto3 presence keeps null distinguishable from empty. - `GrpcSecretSyncService` — `SqlServerSecretSyncService` with the push half removed. One `ReconcileAsync` per tick (pull-only by the reconciler's own contract, and batched, so a cold-start pull set cannot exceed gRPC's 4 MB cap), never throws out of `ExecuteAsync`, warns once and retries on the next interval. The local store is left completely undecorated — the sweep is its only hub-ward writer. - `SecretsGrpcHubClientOptions` — fail-closed `Validate()` naming the exact configuration key, including the section a caller actually bound. The bearer token is never echoed by any message. - `AddZbSecretsGrpcHubClient` — binds, eagerly validates, and registers the channel under a service KEY so it cannot lose a `TryAdd` race to an application's own `GrpcChannel` and dial the wrong endpoint with the hub credential attached. Tests 52/52 (red-first), including a source-scan guard pinning that no client file names `ComputePushSet`, `ReadLocalAsync`, or `ApplyReplicatedAsync`, and a reflection pin that the reader seam exposes exactly two read operations. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
+94
@@ -0,0 +1,94 @@
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection entry point for a site node that follows the central-hosted secrets hub.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The hub and every follower need the same KEK.</b> Only ciphertext crosses the wire, so a node
|
||||
/// whose master key differs cannot decrypt rows central wrote — it fails closed on resolve with a
|
||||
/// <c>kek_id</c> mismatch, which reads like data corruption but is a deployment error.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This is the follower half only.</b> Central hosts the hub service and must not register this
|
||||
/// client; a site node registers this client and must not host the service. Nothing here can push a
|
||||
/// row hub-ward — the contract has no write RPC — so the direction of replication is a property of
|
||||
/// the deployment topology rather than a setting that could be got wrong.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SecretsGrpcHubClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Service key under which this package's <see cref="GrpcChannel"/> is registered, so it can
|
||||
/// never be confused with a channel the application registered for something else.
|
||||
/// </summary>
|
||||
public const string ChannelServiceKey = "zb-secrets-grpc-hub";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the pull-only hub client and its background sweep: a channel to the configured
|
||||
/// endpoint, the generated gRPC client, the reader seam over it, and a hosted service that keeps
|
||||
/// the node's local store converged with central.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The local <see cref="ISecretStore"/> is <b>not</b> registered or decorated here — call
|
||||
/// <c>AddZbSecrets</c> as usual and this sits alongside it. That is the whole difference from the
|
||||
/// SQL-Server hub-replication mode, which wraps the store in a publishing decorator: pull-only
|
||||
/// means local writes never leave the node, so there is nothing to publish and the store stays
|
||||
/// exactly as the application configured it. A host that forgets <c>AddZbSecrets</c> fails loudly
|
||||
/// at startup when the sweep cannot resolve the store, rather than silently replicating nothing.
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <param name="sectionPath">Configuration section holding the hub-client options.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The bound options are unusable — see <see cref="SecretsGrpcHubClientOptions.Validate"/>.
|
||||
/// </exception>
|
||||
public static IServiceCollection AddZbSecretsGrpcHubClient(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionPath = SecretsGrpcHubClientOptions.DefaultSectionPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
IConfigurationSection section = configuration.GetSection(sectionPath);
|
||||
services.Configure<SecretsGrpcHubClientOptions>(section);
|
||||
|
||||
// Bind and validate NOW as well as through the options pipeline: a missing endpoint or an
|
||||
// unset bearer token should fail at startup with a message naming the key, not surface as a
|
||||
// sweep that warns once every interval and never converges. The eagerly-bound instance is
|
||||
// discarded immediately — it is a credential holder, and everything downstream reads the
|
||||
// options from IOptions rather than closing over it.
|
||||
var eager = new SecretsGrpcHubClientOptions();
|
||||
section.Bind(eager);
|
||||
eager.Validate(sectionPath);
|
||||
|
||||
// Singleton, so the container owns its disposal — a channel per sweep would leak an HTTP/2
|
||||
// connection every interval. KEYED, because GrpcChannel is a framework type an application
|
||||
// very plausibly registers for one of its own gRPC dependencies: an unkeyed TryAddSingleton
|
||||
// would silently lose to that registration and this client would then dial someone else's
|
||||
// endpoint with the hub credential attached.
|
||||
services.TryAddKeyedSingleton(ChannelServiceKey, (sp, _) => GrpcChannel.ForAddress(
|
||||
sp.GetRequiredService<IOptions<SecretsGrpcHubClientOptions>>().Value.Endpoint));
|
||||
|
||||
services.TryAddSingleton<ISecretsHubReader>(sp => new GrpcSecretsHubClient(
|
||||
new SecretsHub.SecretsHubClient(
|
||||
sp.GetRequiredKeyedService<GrpcChannel>(ChannelServiceKey)),
|
||||
sp.GetRequiredService<IOptions<SecretsGrpcHubClientOptions>>(),
|
||||
sp.GetService<TimeProvider>()));
|
||||
|
||||
services.AddHostedService<GrpcSecretSyncService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replication;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a site node's local secret store converged with the central-hosted hub by running a
|
||||
/// <b>pull-only</b> anti-entropy sweep on an interval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the SQL-Server package's sync service with one half removed, and the removal is the
|
||||
/// design. In this topology every update originates at central and a site node is a strict follower,
|
||||
/// so there is no push set to compute and no local write to propagate outward. The wire contract has
|
||||
/// no write RPC to push with either — the narrow contract IS the enforcement, and this service is
|
||||
/// kept honest to match it so the invariant reads the same from both ends.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The local store is left completely untouched as a write target for ordinary local use: nothing
|
||||
/// here decorates it, so local reads and any local writes behave exactly as they did before
|
||||
/// replication was switched on. This sweep is the only writer that carries hub state into it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The sweep never throws out of <see cref="ExecuteAsync"/>: a hub outage must degrade the node to
|
||||
/// "serving its last-known-good local secrets" rather than crash the host. Failures are logged once
|
||||
/// and retried on the next tick.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
{
|
||||
private readonly SecretReplicationReconciler _reconciler;
|
||||
private readonly ISecretsHubReader _hub;
|
||||
private readonly SecretsGrpcHubClientOptions _options;
|
||||
private readonly ILogger<GrpcSecretSyncService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>Creates the sync service.</summary>
|
||||
/// <param name="local">The node's local store — the only thing this service writes to.</param>
|
||||
/// <param name="hub">The central hub, read-only.</param>
|
||||
/// <param name="cacheInvalidator">Resolver-cache seam, evicted for every applied row.</param>
|
||||
/// <param name="options">Hub-client options.</param>
|
||||
/// <param name="logger">Logger for sweep outcomes and failures.</param>
|
||||
/// <param name="timeProvider">Clock, injectable so tests need not wait a real interval.</param>
|
||||
public GrpcSecretSyncService(
|
||||
ISecretStore local,
|
||||
ISecretsHubReader hub,
|
||||
ISecretCacheInvalidator? cacheInvalidator,
|
||||
IOptions<SecretsGrpcHubClientOptions> options,
|
||||
ILogger<GrpcSecretSyncService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
|
||||
_hub = hub ?? throw new ArgumentNullException(nameof(hub));
|
||||
_options = options.Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_reconciler = new SecretReplicationReconciler(
|
||||
local,
|
||||
cacheInvalidator,
|
||||
onRowFailed: (name, ex) => LogRowDiscarded(ex, name.Value));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (_options.SyncOnStartup)
|
||||
{
|
||||
await SweepSafelyAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using PeriodicTimer timer = new(_options.SyncInterval, _timeProvider);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
await SweepSafelyAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one pull-only reconcile against the hub.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the sweep.</param>
|
||||
/// <returns>How many rows were pulled from the hub.</returns>
|
||||
internal async Task<int> SweepAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<SecretManifestEntry> hubManifest =
|
||||
await _hub.GetManifestAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// The reconciler's pull path is exactly the required algorithm — diff the hub's manifest
|
||||
// against the local one, fetch what the hub holds newer, apply last-writer-wins, evict the
|
||||
// resolver cache per applied row — and it is documented as "a pull, never a push". Calling
|
||||
// it rather than re-deriving the diff here also keeps the batching: a cold-starting node's
|
||||
// pull set is central's ENTIRE inventory, and an unchunked GetSecrets would exceed gRPC's
|
||||
// 4 MB default message cap and then fail identically on every interval forever.
|
||||
return await _reconciler
|
||||
.ReconcileAsync(hubManifest, (names, token) => _hub.GetManyAsync(names, token), ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task SweepSafelyAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
int pulled = await SweepAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (pulled > 0)
|
||||
{
|
||||
LogSweepConverged(pulled);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Host shutdown — not a failure.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Deliberately catch-all. Everything reachable from here — an unreachable hub, a
|
||||
// rejected credential, a deadline, a row the hub sent that this node's parse refuses —
|
||||
// has the same correct response: keep serving the local store and try again. Letting any
|
||||
// of it escape would fault the BackgroundService and take the host down over a transient
|
||||
// central outage, which is precisely the availability the pull-only topology exists for.
|
||||
LogSweepFailed(ex);
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Secret hub sync converged: pulled {Pulled} row(s) from the central hub.")]
|
||||
private partial void LogSweepConverged(int pulled);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Secret hub sync failed; the node continues serving its local store and will retry on the next interval.")]
|
||||
private partial void LogSweepFailed(Exception exception);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 3,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")]
|
||||
private partial void LogRowDiscarded(Exception exception, string secretName);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// The read half of the hub, as a follower sees it: compare inventories, then fetch rows in bulk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the whole of what a site node may do to central's secret store, and the interface says so
|
||||
/// — there is no apply, no upsert, no delete. It mirrors the read half of the SQL-Server package's
|
||||
/// <c>ISecretReplicationHub</c> without referencing it: the two topologies are deliberately separate
|
||||
/// packages, and importing that interface would drag a SQL-Server dependency onto every site node
|
||||
/// while also handing this one a write method it must never call.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It exists as a seam so the sweep is testable against an in-process fake instead of needing a live
|
||||
/// gRPC server for every convergence case.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal interface ISecretsHubReader
|
||||
{
|
||||
/// <summary>Returns the hub's manifest — names, revisions, timestamps, tombstone flags.</summary>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The hub's manifest entries.</returns>
|
||||
Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Fetches several encrypted rows by name; names absent from the hub are omitted.</summary>
|
||||
/// <param name="names">The names to fetch.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The matching encrypted rows.</returns>
|
||||
Task<IReadOnlyList<StoredSecret>> GetManyAsync(IReadOnlyList<SecretName> names, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ISecretsHubReader"/> over the generated <c>secrets_hub.v1</c> gRPC client: attaches the
|
||||
/// bearer credential and a per-call deadline, and converts every response through
|
||||
/// <see cref="WireToDomain"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Faults are deliberately <b>not</b> caught here. An <see cref="RpcException"/> is the honest signal
|
||||
/// that the hub did not answer, and the sweep — the only caller — is where that is turned into "keep
|
||||
/// serving the last-known-good local store and retry next tick". Swallowing it here would make an
|
||||
/// unreachable hub indistinguishable from an empty one, which is the one failure mode this
|
||||
/// subsystem must never have.
|
||||
/// </remarks>
|
||||
internal sealed class GrpcSecretsHubClient : ISecretsHubReader
|
||||
{
|
||||
private const string AuthorizationHeader = "Authorization";
|
||||
|
||||
private readonly SecretsHub.SecretsHubClient _client;
|
||||
private readonly SecretsGrpcHubClientOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>Creates the hub client.</summary>
|
||||
/// <param name="client">The generated gRPC client, bound to the configured channel.</param>
|
||||
/// <param name="options">Endpoint, credential, and per-call deadline.</param>
|
||||
/// <param name="timeProvider">Clock used to compute the per-call deadline.</param>
|
||||
public GrpcSecretsHubClient(
|
||||
SecretsHub.SecretsHubClient client,
|
||||
IOptions<SecretsGrpcHubClientOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
|
||||
{
|
||||
GetManifestResponse response = await _client
|
||||
.GetManifestAsync(new GetManifestRequest(), CreateCallOptions(ct))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return WireToDomain.ToManifest(response.Entries);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoredSecret>> GetManyAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(names);
|
||||
|
||||
var request = new GetSecretsRequest();
|
||||
|
||||
foreach (SecretName name in names)
|
||||
{
|
||||
request.Names.Add(name.Value);
|
||||
}
|
||||
|
||||
GetSecretsResponse response = await _client
|
||||
.GetSecretsAsync(request, CreateCallOptions(ct))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return WireToDomain.ToRows(response.Secrets);
|
||||
}
|
||||
|
||||
// Built per call rather than once: the deadline is an absolute instant, so a cached CallOptions
|
||||
// would carry a deadline that expired shortly after startup and then fail every sweep forever.
|
||||
private CallOptions CreateCallOptions(CancellationToken ct) => new(
|
||||
headers: new Metadata { { AuthorizationHeader, $"Bearer {_options.BearerToken}" } },
|
||||
deadline: _timeProvider.GetUtcNow().UtcDateTime.Add(_options.CallDeadline),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a site node that follows the central-hosted secrets hub. Bound from a
|
||||
/// configuration section by <c>AddZbSecretsGrpcHubClient</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every field here describes how to <em>reach</em> the hub. There is deliberately no knob that
|
||||
/// changes the direction of replication: this package is pull-only by construction and the wire
|
||||
/// contract carries no write RPC, so a follower cannot be configured into pushing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SecretsGrpcHubClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The hub's gRPC address, as an absolute <c>http</c> or <c>https</c> URI (for example
|
||||
/// <c>https://central:5001</c>). Required.
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Shared bearer token presented on every call. Required — an unset token is a hard startup
|
||||
/// failure rather than an anonymous call, because the hub authenticates on this alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This value is never logged, never included in an exception message, and never echoed back by
|
||||
/// validation: a startup log is read by far more people than the deployment secret store is.
|
||||
/// Supply it the way the mesh PSKs are supplied — appsettings or environment — <b>not</b> as a
|
||||
/// <c>${secret:}</c> reference, which would need the very store this client exists to fill.
|
||||
/// </remarks>
|
||||
public string BearerToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// How often the follower sweeps the hub for rows it is missing or behind on. Defaults to
|
||||
/// 30 seconds. Must be positive.
|
||||
/// </summary>
|
||||
public TimeSpan SyncInterval { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/> (the default), a sweep runs at startup before the first interval
|
||||
/// elapses, so a node that was down while central wrote does not serve stale secrets for a whole
|
||||
/// interval after it comes back.
|
||||
/// </summary>
|
||||
public bool SyncOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline applied to each hub call. Defaults to 30 seconds — long enough for a busy central
|
||||
/// node, short enough that an unreachable hub fails the sweep and is retried on the next tick
|
||||
/// instead of pinning the sweep loop indefinitely.
|
||||
/// </summary>
|
||||
public TimeSpan CallDeadline { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the options are unusable. Called by the DI extension at registration time so a
|
||||
/// misconfigured node fails at startup rather than on the first silent sweep failure.
|
||||
/// </summary>
|
||||
/// <param name="sectionPath">
|
||||
/// The configuration section the values were bound from, so each message names the key an
|
||||
/// operator actually has to edit rather than the default one.
|
||||
/// </param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The endpoint is missing or is not an absolute http/https URI, the bearer token is missing, or
|
||||
/// an interval is not positive.
|
||||
/// </exception>
|
||||
public void Validate(string sectionPath = DefaultSectionPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client requires an Endpoint ({sectionPath}:Endpoint).");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out Uri? uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
// Rejected here rather than at GrpcChannel.ForAddress, which throws deep inside the
|
||||
// first sweep — by which point the failure looks like a hub outage instead of a typo.
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client Endpoint must be an absolute http or https URI " +
|
||||
$"({sectionPath}:Endpoint, was '{Endpoint}').");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BearerToken))
|
||||
{
|
||||
// Fail closed. An empty token would otherwise be presented as `Bearer `, which the hub
|
||||
// rejects on every call — an authentication outage that looks like a network problem.
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client requires a BearerToken ({sectionPath}:BearerToken); " +
|
||||
"it is unset, so this node has no way to authenticate to the hub.");
|
||||
}
|
||||
|
||||
if (SyncInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client SyncInterval must be positive " +
|
||||
$"({sectionPath}:SyncInterval, was {SyncInterval}).");
|
||||
}
|
||||
|
||||
if (CallDeadline <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client CallDeadline must be positive " +
|
||||
$"({sectionPath}:CallDeadline, was {CallDeadline}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The configuration section these options are bound from unless a caller overrides it.</summary>
|
||||
public const string DefaultSectionPath = "Secrets:GrpcHub";
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds domain types from the <c>secrets_hub.v1</c> wire contract. This is the trust boundary a
|
||||
/// follower applies to everything the hub sends it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Parsing here is <b>strict, and rejection is per row</b>: a value protobuf accepted but the domain
|
||||
/// would not — a name that is rooted or path-traversing, a content type naming no enum member, a
|
||||
/// timestamp that is not round-trip ISO-8601 — throws rather than being coerced. The sweep's
|
||||
/// reconciler isolates the failure to that one row (see <c>SecretReplicationReconciler.ApplyAsync</c>),
|
||||
/// so a single bad row is logged and skipped instead of poisoning the batch behind it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This mirrors the Akka replicator's <c>SecretRowDto.ToStoredSecret</c> deliberately — one
|
||||
/// fail-closed parse shape family-wide, so the two transports cannot disagree about what a
|
||||
/// replicated row is allowed to contain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two checks the Akka DTO makes are <em>absent</em> here on purpose. Protobuf cannot express a null
|
||||
/// <c>bytes</c> or a null <c>string</c> — an omitted field decodes to empty, never null — so the
|
||||
/// null-blob guards have nothing to catch. Emptiness itself is deliberately not judged: an empty
|
||||
/// <see cref="StoredSecret.Ciphertext"/> is legitimate (AES-GCM ciphertext is the length of its
|
||||
/// plaintext, so a secret whose value is the empty string has exactly that), and rejecting it here
|
||||
/// would refuse a valid row. A structurally nonsensical row fails closed one layer down, at decrypt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class WireToDomain
|
||||
{
|
||||
/// <summary>Rebuilds a manifest from the wire form, in the order the hub sent it.</summary>
|
||||
/// <param name="entries">The wire manifest entries.</param>
|
||||
/// <returns>The domain manifest entries.</returns>
|
||||
public static IReadOnlyList<SecretManifestEntry> ToManifest(IEnumerable<ManifestEntry> entries)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
return [.. entries.Select(ToEntry)];
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds one manifest entry from the wire form.</summary>
|
||||
/// <param name="entry">The wire manifest entry.</param>
|
||||
/// <returns>The domain manifest entry.</returns>
|
||||
/// <exception cref="ArgumentException">The name is not a valid secret name.</exception>
|
||||
/// <exception cref="FormatException">The timestamp is not a round-trip ISO-8601 value.</exception>
|
||||
public static SecretManifestEntry ToEntry(ManifestEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
return new SecretManifestEntry
|
||||
{
|
||||
// SecretName re-validates on construction, so a malformed or path-traversing name from
|
||||
// the hub is rejected here rather than reaching the store.
|
||||
Name = new SecretName(entry.Name),
|
||||
Revision = entry.Revision,
|
||||
UpdatedUtc = ParseTimestamp(entry.UpdatedUtc),
|
||||
IsDeleted = entry.IsDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds encrypted rows from the wire form, in the order the hub sent them.</summary>
|
||||
/// <param name="rows">The wire rows.</param>
|
||||
/// <returns>The domain rows.</returns>
|
||||
public static IReadOnlyList<StoredSecret> ToRows(IEnumerable<SecretRow> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
|
||||
return [.. rows.Select(ToRow)];
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds one encrypted row from the wire form.</summary>
|
||||
/// <param name="row">The wire row.</param>
|
||||
/// <returns>The domain row.</returns>
|
||||
/// <exception cref="ArgumentException">The name or content type is not valid.</exception>
|
||||
/// <exception cref="FormatException">A timestamp is not a round-trip ISO-8601 value.</exception>
|
||||
public static StoredSecret ToRow(SecretRow row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
return new StoredSecret
|
||||
{
|
||||
Name = new SecretName(row.Name),
|
||||
// Explicit presence, not `?? null`: proto3 optional is what keeps a description that was
|
||||
// deliberately set to the empty string distinguishable from one that was never set.
|
||||
Description = row.HasDescription ? row.Description : null,
|
||||
ContentType = ParseContentType(row.ContentType),
|
||||
Ciphertext = row.Ciphertext.ToByteArray(),
|
||||
Nonce = row.Nonce.ToByteArray(),
|
||||
Tag = row.Tag.ToByteArray(),
|
||||
WrappedDek = row.WrappedDek.ToByteArray(),
|
||||
WrapNonce = row.WrapNonce.ToByteArray(),
|
||||
WrapTag = row.WrapTag.ToByteArray(),
|
||||
KekId = row.KekId,
|
||||
Revision = row.Revision,
|
||||
IsDeleted = row.IsDeleted,
|
||||
DeletedUtc = row.HasDeletedUtc ? ParseTimestamp(row.DeletedUtc) : null,
|
||||
CreatedUtc = ParseTimestamp(row.CreatedUtc),
|
||||
UpdatedUtc = ParseTimestamp(row.UpdatedUtc),
|
||||
CreatedBy = row.HasCreatedBy ? row.CreatedBy : null,
|
||||
UpdatedBy = row.HasUpdatedBy ? row.UpdatedBy : null,
|
||||
};
|
||||
}
|
||||
|
||||
// RoundtripKind, so an offset-bearing "O" string comes back with its offset intact. The hub and
|
||||
// the follower compare UpdatedUtc for last-writer-wins, so "close enough" is not enough: a value
|
||||
// that shifted by a tick or lost its offset would make a converged pair look permanently unequal.
|
||||
private static DateTimeOffset ParseTimestamp(string value) => DateTimeOffset.Parse(
|
||||
value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
|
||||
// Enum.Parse is wrong for untrusted input on two counts: a numeric string that overflows the
|
||||
// underlying type throws OverflowException where callers expect ArgumentException, and any
|
||||
// in-range number is accepted even if it names no member — persisting an undefined enum value
|
||||
// that later readers assume is valid.
|
||||
private static SecretContentType ParseContentType(string value) =>
|
||||
Enum.TryParse(value, out SecretContentType parsed) && Enum.IsDefined(parsed)
|
||||
? parsed
|
||||
: throw new ArgumentException(
|
||||
$"Replicated secret row has an unrecognized content type '{value}'.", nameof(value));
|
||||
}
|
||||
Reference in New Issue
Block a user