feat(secrets): Grpc hub server — SecretsHubService + fail-closed bearer interceptor + DI

The server half of the central-hosted pull-only secrets hub. SecretsHubService
adapts the node's own ISecretStore onto secrets_hub.v1 using exactly two read
members (GetManifestAsync, GetAsync) — the proto exposes no write RPC, so a
follower has no path to put a row into central whatever its credentials allow.

GetSecrets omits a name the hub does not hold and skips one that will not parse
as a SecretName; neither is an error, because both describe a follower acting on
a manifest that was true a moment ago. Failing the call would stall the sweep and
let one bad name deny the follower every other row in the batch. Names are
re-validated server-side (the wire type is a plain string) and de-duplicated on
the normalized value.

DomainToWire mirrors the Akka replicator's SecretRowDto discipline exactly:
round-trip "O" timestamps so a DateTimeOffset's offset and ticks survive the
last-writer-wins equality compare, the content type as its enum MEMBER NAME so an
unknown value is rejected rather than decoded to the zero member, and nullable
fields left absent rather than written as "" so null and empty stay distinct.

SecretsHubAuthInterceptor fails closed: with no token configured every call is
denied and one warning names the exact configuration key — never a token value,
presented or configured, at any level. Denial is uniform (one Status, one detail,
no "why") so a caller gets no oracle. Comparison is constant-time over UTF-8
bytes. All four handler shapes are gated, not just unary, so a future RPC cannot
arrive unauthenticated by omission; more than one Authorization header is refused
as a smuggling shape. The type keeps EXACTLY ONE public constructor — Grpc's
activator silently stops invoking an interceptor it cannot construct
unambiguously, and the failure mode is an auth gate that quietly does not run — so
a test pins the count.

Tests: 50, red-first (compile-red, then a fail-closed mutation verified 5 of them
bite). Full Secrets suite 328 passed / 0 failed / 15 live-gated skips, 0 new
warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-07 06:09:23 -04:00
parent cc95590196
commit 7fc77f2790
8 changed files with 1290 additions and 0 deletions
@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
/// <summary>
/// Dependency-injection and endpoint wiring for hosting the central secrets hub.
/// </summary>
/// <remarks>
/// <para>
/// Register this on the <b>central</b> node only. A site node registers the client half instead —
/// the two are separate registrations on purpose, so "central hosts, sites follow" is visible in
/// each role's composition root rather than inferred from configuration.
/// </para>
/// <para>
/// <b>Every node must be configured with the same KEK.</b> The hub serves ciphertext, so a follower
/// whose master key differs cannot decrypt what it pulls; it fails closed on resolve with a
/// <c>kek_id</c> mismatch, which reads like data corruption but is a deployment error.
/// </para>
/// </remarks>
public static class SecretsGrpcHubServerExtensions
{
/// <summary>
/// Binds and validates <see cref="SecretsGrpcHubServerOptions"/>, registers the fail-closed
/// bearer interceptor, and attaches it to <see cref="SecretsHubService"/>.
/// </summary>
/// <remarks>
/// Validation runs here, eagerly, as well as through the options pipeline: a hub with no bearer
/// token should refuse to start rather than start and refuse every follower. The interceptor's
/// deny-all is the second layer, for the case where a host constructs the options some other way.
/// The service requires an <c>ISecretStore</c> in the container — register the store first.
/// </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 options.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
/// <exception cref="InvalidOperationException">No bearer token is configured.</exception>
public static IServiceCollection AddZbSecretsGrpcHub(
this IServiceCollection services,
IConfiguration configuration,
string sectionPath = "Secrets:GrpcHub")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
IConfigurationSection section = configuration.GetSection(sectionPath);
services.Configure<SecretsGrpcHubServerOptions>(section);
// SectionPath is internal, so binding cannot set it; stamping it here is what lets the
// startup failure and the runtime warning both name the key this host actually reads.
services.PostConfigure<SecretsGrpcHubServerOptions>(options => options.SectionPath = sectionPath);
var eager = new SecretsGrpcHubServerOptions { SectionPath = sectionPath };
section.Bind(eager);
eager.SectionPath = sectionPath;
eager.Validate();
// Singleton so the "unconfigured" warning is emitted once per process rather than once per
// interceptor instance; the gRPC interceptor activator prefers a registered instance.
services.TryAddSingleton<SecretsHubAuthInterceptor>();
// AddGrpc is safe to call again if the host already called it — it registers with TryAdd.
services.AddGrpc()
.AddServiceOptions<SecretsHubService>(
options => options.Interceptors.Add<SecretsHubAuthInterceptor>());
return services;
}
/// <summary>
/// Maps the secrets hub gRPC endpoint. The host must have set up routing;
/// <see cref="AddZbSecretsGrpcHub"/> supplies the gRPC services and the auth interceptor.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The same <paramref name="endpoints"/> instance, for chaining.</returns>
public static IEndpointRouteBuilder MapZbSecretsHub(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapGrpcService<SecretsHubService>();
return endpoints;
}
}
@@ -0,0 +1,58 @@
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
/// <summary>
/// Configuration for the central-hosted secrets hub. Bound from a configuration section by
/// <c>AddZbSecretsGrpcHub</c>.
/// </summary>
/// <remarks>
/// <para>
/// The hub serves ciphertext to followers that authenticate with one shared bearer token. That
/// token is the whole of the hub's authorization: there is no per-follower identity, because a
/// follower's authority is uniform (read the manifest, read rows) and the pull-only contract gives
/// it nothing else to be authorized for.
/// </para>
/// <para>
/// It cannot itself be supplied as a <c>${secret:}</c> reference — resolving that reference is what
/// the hub exists to make possible — so it comes from appsettings or the environment, the same way
/// the family's mesh pre-shared keys do.
/// </para>
/// </remarks>
public sealed class SecretsGrpcHubServerOptions
{
/// <summary>The configuration section the hub is bound from unless a host overrides it.</summary>
internal const string DefaultSectionPath = "Secrets:GrpcHub";
/// <summary>
/// The shared bearer token a follower must present. Required — an unset token is not "open
/// access", it is a hub that refuses every call (see <see cref="SecretsHubAuthInterceptor"/>).
/// </summary>
public string BearerToken { get; set; } = string.Empty;
/// <summary>
/// The configuration section this instance was bound from, used so failure messages name the
/// key an operator actually has to set.
/// </summary>
/// <remarks>
/// Deliberately <see langword="internal"/>: configuration binding only writes public settable
/// properties, so a stray <c>SectionPath</c> key in an appsettings file cannot make the hub
/// misreport which key it wants. The registration extension sets it.
/// </remarks>
internal string SectionPath { get; set; } = DefaultSectionPath;
/// <summary>
/// Throws if the options are unusable. Called by the registration extension so a hub with no
/// token fails at startup, rather than starting and rejecting every follower at runtime.
/// </summary>
/// <exception cref="InvalidOperationException">The bearer token is missing or blank.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(BearerToken))
{
// Names the key, never the value — there is no value here, and there never will be one
// in an error message from this type.
throw new InvalidOperationException(
$"The gRPC secrets hub requires a shared bearer token ({SectionPath}:BearerToken). " +
"Until it is set the hub refuses every call.");
}
}
}
@@ -0,0 +1,200 @@
using System.Security.Cryptography;
using System.Text;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
/// <summary>
/// Gates every call to the secrets hub on a shared bearer token presented as
/// <c>Authorization: Bearer &lt;token&gt;</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Fail closed.</b> If no token is configured, every call is denied — an unconfigured hub is a
/// hub that serves nothing, never a hub that serves everyone. The refusal is paired with exactly one
/// warning naming the configuration key an operator has to set.
/// </para>
/// <para>
/// <b>Uniform denial.</b> Every rejection — absent header, wrong scheme, wrong token, unconfigured
/// hub — throws the identical <see cref="StatusCode.Unauthenticated"/> status with the identical
/// detail. A caller learns that it was refused and nothing else; distinguishing "no token" from
/// "wrong token" would hand an attacker a free oracle.
/// </para>
/// <para>
/// <b>No token material is ever logged</b>, presented or configured, at any level.
/// </para>
/// </remarks>
public sealed class SecretsHubAuthInterceptor : Interceptor
{
/// <summary>gRPC normalizes metadata keys to lower case, so the lookup key is lower case too.</summary>
private const string AuthorizationHeaderName = "authorization";
/// <summary>
/// Matched ordinally, including case. RFC 7235 makes the scheme case-insensitive, but this is a
/// machine-to-machine channel with one client implementation, so the stricter rule costs nothing
/// and removes a class of near-miss that would otherwise have to be reasoned about.
/// </summary>
private const string BearerScheme = "Bearer ";
/// <summary>The one detail every denial carries. It says nothing about why.</summary>
private const string DenialDetail = "Unauthenticated.";
private readonly byte[] _expectedTokenUtf8;
private readonly string _bearerTokenConfigurationKey;
private readonly ILogger<SecretsHubAuthInterceptor> _logger;
private int _unconfiguredWarningEmitted;
/// <summary>
/// Creates the interceptor over the hub's configured token.
/// </summary>
/// <remarks>
/// <b>This type must keep EXACTLY ONE public constructor.</b> Grpc.AspNetCore activates an
/// interceptor through <c>ActivatorUtilities</c>, which cannot choose between overloads — an
/// interceptor with more than one public constructor silently stops being invoked, and the
/// failure mode is an authentication gate that quietly does not run. A test pins the count.
/// </remarks>
/// <param name="options">The hub options carrying the shared bearer token.</param>
/// <param name="logger">Diagnostics sink. Never receives token material.</param>
public SecretsHubAuthInterceptor(
IOptions<SecretsGrpcHubServerOptions> options,
ILogger<SecretsHubAuthInterceptor> logger)
{
ArgumentNullException.ThrowIfNull(options);
SecretsGrpcHubServerOptions value = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_bearerTokenConfigurationKey = $"{value.SectionPath}:BearerToken";
_expectedTokenUtf8 = string.IsNullOrWhiteSpace(value.BearerToken)
? []
: Encoding.UTF8.GetBytes(value.BearerToken);
}
/// <inheritdoc/>
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
ArgumentNullException.ThrowIfNull(continuation);
Authenticate(context);
return continuation(request, context);
}
/// <inheritdoc/>
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
{
ArgumentNullException.ThrowIfNull(continuation);
Authenticate(context);
return continuation(requestStream, context);
}
/// <inheritdoc/>
public override Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
ArgumentNullException.ThrowIfNull(continuation);
Authenticate(context);
return continuation(request, responseStream, context);
}
/// <inheritdoc/>
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
{
ArgumentNullException.ThrowIfNull(continuation);
Authenticate(context);
return continuation(requestStream, responseStream, context);
}
/// <summary>
/// Admits the call or throws the one denial. The streaming overloads are gated as well as the
/// unary ones even though the contract is unary-only today, so a future RPC of any shape cannot
/// arrive unauthenticated by omission.
/// </summary>
private void Authenticate(ServerCallContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (_expectedTokenUtf8.Length == 0)
{
WarnUnconfiguredOnce();
throw Denied();
}
Metadata headers = context.RequestHeaders;
// More than one Authorization header is a smuggling shape, not a legitimate request: taking
// "the first one" would let a caller present a second value that some other hop honours.
int authorizationHeaderCount = 0;
string? presented = null;
foreach (Metadata.Entry entry in headers)
{
if (!string.Equals(entry.Key, AuthorizationHeaderName, StringComparison.Ordinal))
{
continue;
}
authorizationHeaderCount++;
presented ??= entry.Value;
}
if (authorizationHeaderCount != 1 || presented is null)
{
throw Denied();
}
if (!presented.StartsWith(BearerScheme, StringComparison.Ordinal))
{
throw Denied();
}
// FixedTimeEquals compares in time independent of the contents, and returns false for a
// length mismatch without comparing at all — so the only thing a caller can time is the
// length of its own token.
byte[] presentedTokenUtf8 = Encoding.UTF8.GetBytes(presented[BearerScheme.Length..]);
if (!CryptographicOperations.FixedTimeEquals(presentedTokenUtf8, _expectedTokenUtf8))
{
throw Denied();
}
}
/// <summary>
/// Builds the single denial every failure path throws. A fresh exception each time (the stack
/// trace is per-call) carrying an identical status.
/// </summary>
private static RpcException Denied() => new(new Status(StatusCode.Unauthenticated, DenialDetail));
/// <summary>
/// Warns once — not once per call — that the hub cannot serve, naming the exact key to set. A
/// per-call warning would bury the one line that matters under the flood it describes.
/// </summary>
private void WarnUnconfiguredOnce()
{
if (Interlocked.Exchange(ref _unconfiguredWarningEmitted, 1) != 0)
{
return;
}
_logger.LogWarning(
"The gRPC secrets hub has no bearer token configured ({BearerTokenConfigurationKey}); " +
"every call is being refused until it is set.",
_bearerTokenConfigurationKey);
}
}
@@ -0,0 +1,131 @@
using Grpc.Core;
using Microsoft.Extensions.Logging;
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 central-hosted secrets hub: adapts the node's own <see cref="ISecretStore"/> onto the
/// <c>secrets_hub.v1</c> contract so site nodes can pull from it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Read-only by construction.</b> It touches exactly two store members —
/// <see cref="ISecretStore.GetManifestAsync"/> and <see cref="ISecretStore.GetAsync"/> — mirroring
/// the read half of <c>ISecretReplicationHub</c>. The proto exposes no write RPC, so there is no
/// path by which a follower can put a row into central, whatever its credentials allow.
/// </para>
/// <para>
/// Everything it returns is ciphertext or metadata about ciphertext. The KEK never travels: a row
/// carries the KEK's identifier and the DEK wrapped by it, and every node in a replicating
/// deployment is configured with the same KEK out of band.
/// </para>
/// </remarks>
public sealed class SecretsHubService : SecretsHub.SecretsHubBase
{
private readonly ISecretStore _store;
private readonly ILogger<SecretsHubService> _logger;
/// <summary>Creates the hub service over the node's secret store.</summary>
/// <param name="store">The store to serve. Only its read members are used.</param>
/// <param name="logger">Diagnostics sink.</param>
public SecretsHubService(ISecretStore store, ILogger<SecretsHubService> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Returns the hub's full manifest, <b>tombstones included</b> — a follower converges on a
/// delete by seeing the tombstone, so omitting them would make deletes unpropagatable.
/// </summary>
/// <param name="request">The (empty) manifest request.</param>
/// <param name="context">The call context.</param>
/// <returns>Every manifest entry the store holds.</returns>
public override async Task<GetManifestResponse> GetManifest(
GetManifestRequest request, ServerCallContext context)
{
ArgumentNullException.ThrowIfNull(context);
IReadOnlyList<SecretManifestEntry> entries =
await _store.GetManifestAsync(context.CancellationToken).ConfigureAwait(false);
var response = new GetManifestResponse();
foreach (SecretManifestEntry entry in entries)
{
response.Entries.Add(DomainToWire.ToWire(entry));
}
_logger.LogDebug("Secrets hub served a manifest of {EntryCount} entries.", response.Entries.Count);
return response;
}
/// <summary>
/// Fetches the named encrypted rows in bulk.
/// </summary>
/// <remarks>
/// A requested name the hub does not hold is <b>omitted</b> from the response, and an unparseable
/// name is <b>skipped</b> — neither is an error. Both describe the same ordinary situation: the
/// follower is acting on a manifest that was true a moment ago. Failing the whole call would turn
/// a routine race into a stalled sweep, and would let one bad name deny the follower every other
/// row in the batch.
/// </remarks>
/// <param name="request">The names to fetch.</param>
/// <param name="context">The call context.</param>
/// <returns>One row per distinct name the hub holds.</returns>
public override async Task<GetSecretsResponse> GetSecrets(
GetSecretsRequest request, ServerCallContext context)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(context);
var response = new GetSecretsResponse();
var seen = new HashSet<string>(StringComparer.Ordinal);
int skipped = 0;
foreach (string requested in request.Names)
{
SecretName name;
try
{
// Re-validated server-side: the wire type is a plain string, so this is where a
// path-traversing or otherwise malformed name is stopped — before it reaches a store
// that may treat the name as a path segment.
name = new SecretName(requested);
}
catch (ArgumentException)
{
skipped++;
continue;
}
// Normalization happens in the SecretName constructor, so de-duplication has to key off
// the normalized value — otherwise "Db/Central" and "db/central" would each cost a read
// and each return the same row.
if (!seen.Add(name.Value))
{
continue;
}
StoredSecret? row = await _store.GetAsync(name, context.CancellationToken).ConfigureAwait(false);
if (row is null)
{
continue;
}
response.Secrets.Add(DomainToWire.ToWire(row));
}
if (skipped > 0)
{
// The count, never the values: the names are caller-supplied text and this is a log.
_logger.LogWarning(
"Secrets hub skipped {SkippedCount} unparseable name(s) in a GetSecrets request.", skipped);
}
_logger.LogDebug("Secrets hub served {RowCount} row(s).", response.Secrets.Count);
return response;
}
}
@@ -0,0 +1,98 @@
using System.Globalization;
using Google.Protobuf;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Wire;
/// <summary>
/// Projects the domain types onto the <c>secrets_hub.v1</c> wire messages — the hub's outbound
/// half. The inbound half (wire → domain) lives in <c>WireToDomain</c>.
/// </summary>
/// <remarks>
/// <para>
/// The mapping is a deliberate mirror of the Akka replicator's <c>SecretRowDto.FromStoredSecret</c>
/// and follows the same two rules, for the same reasons. Timestamps use the round-trip <c>"O"</c>
/// format so a <see cref="DateTimeOffset"/>'s offset and 100 ns ticks survive intact — last-writer-wins
/// compares these for equality, so "close" is wrong. Content type travels as the enum MEMBER NAME,
/// so an unrecognized value is rejected at the far boundary instead of silently decoding to the
/// zero member.
/// </para>
/// <para>
/// Nullable .NET fields map onto proto3 explicit-presence fields and are left <em>absent</em> when
/// null rather than written as <c>""</c>, so null and empty string stay distinguishable end to end.
/// </para>
/// </remarks>
internal static class DomainToWire
{
/// <summary>The round-trip ("O") format specifier used for every timestamp on this wire.</summary>
private const string RoundTripFormat = "O";
/// <summary>Projects a manifest entry onto its wire message.</summary>
/// <param name="entry">The manifest entry to project.</param>
/// <returns>The wire message.</returns>
public static ManifestEntry ToWire(SecretManifestEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
return new ManifestEntry
{
Name = entry.Name.Value,
Revision = entry.Revision,
UpdatedUtc = Format(entry.UpdatedUtc),
IsDeleted = entry.IsDeleted,
};
}
/// <summary>Projects an encrypted row onto its wire message.</summary>
/// <param name="row">The encrypted row to project.</param>
/// <returns>The wire message — ciphertext and metadata about ciphertext, never key material.</returns>
public static SecretRow ToWire(StoredSecret row)
{
ArgumentNullException.ThrowIfNull(row);
var wire = new SecretRow
{
Name = row.Name.Value,
ContentType = row.ContentType.ToString(),
Ciphertext = ByteString.CopyFrom(row.Ciphertext),
Nonce = ByteString.CopyFrom(row.Nonce),
Tag = ByteString.CopyFrom(row.Tag),
WrappedDek = ByteString.CopyFrom(row.WrappedDek),
WrapNonce = ByteString.CopyFrom(row.WrapNonce),
WrapTag = ByteString.CopyFrom(row.WrapTag),
KekId = row.KekId,
Revision = row.Revision,
IsDeleted = row.IsDeleted,
CreatedUtc = Format(row.CreatedUtc),
UpdatedUtc = Format(row.UpdatedUtc),
};
// Assigned only when present: a proto3 optional string field rejects null, and writing ""
// instead would erase the distinction between "no description" and "an empty one".
if (row.Description is not null)
{
wire.Description = row.Description;
}
if (row.DeletedUtc is { } deleted)
{
wire.DeletedUtc = Format(deleted);
}
if (row.CreatedBy is not null)
{
wire.CreatedBy = row.CreatedBy;
}
if (row.UpdatedBy is not null)
{
wire.UpdatedBy = row.UpdatedBy;
}
return wire;
}
private static string Format(DateTimeOffset value) =>
value.ToString(RoundTripFormat, CultureInfo.InvariantCulture);
}
@@ -0,0 +1,74 @@
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
/// <summary>
/// Registration-time validation for the hub server. A misconfigured hub must fail at startup with
/// a message naming the exact configuration key, not serve unauthenticated at runtime.
/// </summary>
public sealed class SecretsGrpcHubServerOptionsTests
{
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t\n")]
public void Validate_throws_when_the_bearer_token_is_unset(string token)
{
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
InvalidOperationException error =
Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:BearerToken", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_names_the_section_path_the_hub_was_registered_under()
{
var options = new SecretsGrpcHubServerOptions { BearerToken = string.Empty };
options.SectionPath = "Central:Secrets:Hub";
InvalidOperationException error =
Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Central:Secrets:Hub:BearerToken", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_accepts_a_configured_token()
{
var options = new SecretsGrpcHubServerOptions { BearerToken = "a-configured-hub-token" };
options.Validate();
}
[Fact]
public void Validate_never_echoes_the_configured_token()
{
// A token that is present but only whitespace-padded is still a real credential; the
// failure message must never carry it.
const string token = " ";
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
InvalidOperationException error =
Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.DoesNotContain($"'{token}'", error.Message, StringComparison.Ordinal);
Assert.DoesNotContain("was ", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Bearer_token_defaults_to_empty_so_an_unconfigured_hub_cannot_serve()
{
var options = new SecretsGrpcHubServerOptions();
Assert.Equal(string.Empty, options.BearerToken);
Assert.Throws<InvalidOperationException>(() => options.Validate());
}
[Fact]
public void Section_path_defaults_to_the_documented_convention()
{
var options = new SecretsGrpcHubServerOptions();
Assert.Equal("Secrets:GrpcHub", options.SectionPath);
}
}
@@ -0,0 +1,320 @@
using System.Reflection;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
/// <summary>
/// Covers the hub's authentication boundary. Every case here is a security invariant, not a
/// behaviour preference: fail-closed on an unset key, uniform denial that never says <em>why</em>,
/// and no token material in any log line.
/// </summary>
public sealed class SecretsHubAuthInterceptorTests
{
private const string ConfiguredToken = "s3cr3t-hub-token-value";
[Fact]
public async Task Valid_bearer_token_reaches_the_continuation()
{
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
var context = HubServerCallContextStub.WithAuthorization($"Bearer {ConfiguredToken}");
bool invoked = false;
GetManifestResponse response = await interceptor.UnaryServerHandler(
new GetManifestRequest(),
context,
(_, _) =>
{
invoked = true;
return Task.FromResult(new GetManifestResponse());
});
Assert.True(invoked);
Assert.NotNull(response);
}
[Theory]
[InlineData("Bearer wrong-token-value")]
[InlineData("Bearer s3cr3t-hub-token-valu")] // one char short — length mismatch
[InlineData("Bearer s3cr3t-hub-token-values")] // one char long
[InlineData("Bearer S3CR3T-HUB-TOKEN-VALUE")] // case must matter
public async Task Wrong_token_is_denied(string headerValue)
{
await AssertDeniedAsync(Build(ConfiguredToken, out _), headerValue);
}
[Fact]
public async Task Missing_authorization_header_is_denied()
{
await AssertDeniedAsync(Build(ConfiguredToken, out _), header: null);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(ConfiguredToken)] // bare token, no scheme
[InlineData("Basic " + ConfiguredToken)] // wrong scheme
[InlineData("Bearer")] // scheme only
[InlineData("Bearer ")] // scheme, empty token
[InlineData("bearer " + ConfiguredToken)] // scheme case
[InlineData("Bearer " + ConfiguredToken)] // extra space is part of the token
public async Task Malformed_authorization_header_is_denied(string headerValue)
{
await AssertDeniedAsync(Build(ConfiguredToken, out _), headerValue);
}
[Fact]
public async Task Unset_configured_token_denies_a_valid_looking_request()
{
// Fail closed: with no key configured there is no credential that can be correct, so a
// well-formed bearer request is refused exactly like a malformed one.
await AssertDeniedAsync(Build(string.Empty, out _), $"Bearer {ConfiguredToken}");
}
[Fact]
public async Task Unset_configured_token_warns_once_naming_the_exact_config_key()
{
SecretsHubAuthInterceptor interceptor = Build(string.Empty, out HubCapturingLogger<SecretsHubAuthInterceptor> logger);
for (int i = 0; i < 3; i++)
{
await AssertDeniedAsync(interceptor, $"Bearer {ConfiguredToken}");
}
List<string> warnings = logger.Entries
.Where(e => e.Level == LogLevel.Warning)
.Select(e => e.Message)
.ToList();
string warning = Assert.Single(warnings);
Assert.Contains("Secrets:GrpcHub:BearerToken", warning, StringComparison.Ordinal);
}
[Fact]
public async Task Unset_token_warning_names_the_section_path_it_was_registered_under()
{
var options = new SecretsGrpcHubServerOptions { BearerToken = string.Empty };
options.SectionPath = "Central:Secrets:Hub";
var logger = new HubCapturingLogger<SecretsHubAuthInterceptor>();
var interceptor = new SecretsHubAuthInterceptor(Options.Create(options), logger);
await AssertDeniedAsync(interceptor, "Bearer anything");
Assert.Contains(
logger.Entries,
e => e.Level == LogLevel.Warning
&& e.Message.Contains("Central:Secrets:Hub:BearerToken", StringComparison.Ordinal));
}
[Fact]
public async Task Every_failure_mode_produces_the_identical_denial()
{
SecretsHubAuthInterceptor configured = Build(ConfiguredToken, out _);
SecretsHubAuthInterceptor unset = Build(string.Empty, out _);
var details = new List<string>
{
(await AssertDeniedAsync(configured, header: null)).Status.Detail,
(await AssertDeniedAsync(configured, "")).Status.Detail,
(await AssertDeniedAsync(configured, "Basic abc")).Status.Detail,
(await AssertDeniedAsync(configured, "Bearer ")).Status.Detail,
(await AssertDeniedAsync(configured, "Bearer wrong-token-value")).Status.Detail,
(await AssertDeniedAsync(unset, $"Bearer {ConfiguredToken}")).Status.Detail,
};
Assert.Single(details.Distinct(StringComparer.Ordinal));
// And it must not narrate the reason to the caller.
foreach (string detail in details)
{
foreach (string leak in new[] { "expired", "missing", "malformed", "unset", "no token", "wrong" })
{
Assert.DoesNotContain(leak, detail, StringComparison.OrdinalIgnoreCase);
}
}
}
[Fact]
public async Task Denied_call_never_reaches_the_continuation()
{
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
bool invoked = false;
await Assert.ThrowsAsync<RpcException>(() => interceptor.UnaryServerHandler(
new GetManifestRequest(),
HubServerCallContextStub.WithAuthorization("Bearer nope"),
(_, _) =>
{
invoked = true;
return Task.FromResult(new GetManifestResponse());
}));
Assert.False(invoked);
}
[Fact]
public async Task Non_unary_calls_are_gated_too()
{
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
bool invoked = false;
RpcException error = await Assert.ThrowsAsync<RpcException>(
() => interceptor.ServerStreamingServerHandler(
new GetSecretsRequest(),
new HubServerStreamWriterStub<GetSecretsResponse>(),
HubServerCallContextStub.WithAuthorization(header: null),
(_, _, _) =>
{
invoked = true;
return Task.CompletedTask;
}));
Assert.Equal(StatusCode.Unauthenticated, error.StatusCode);
Assert.False(invoked);
}
[Fact]
public async Task No_log_line_ever_contains_token_material()
{
const string presented = "presented-token-that-must-not-be-logged";
SecretsHubAuthInterceptor configured = Build(ConfiguredToken, out HubCapturingLogger<SecretsHubAuthInterceptor> configuredLog);
SecretsHubAuthInterceptor unset = Build(string.Empty, out HubCapturingLogger<SecretsHubAuthInterceptor> unsetLog);
await AssertDeniedAsync(configured, $"Bearer {presented}");
await AssertDeniedAsync(configured, header: null);
await configured.UnaryServerHandler(
new GetManifestRequest(),
HubServerCallContextStub.WithAuthorization($"Bearer {ConfiguredToken}"),
(_, _) => Task.FromResult(new GetManifestResponse()));
await AssertDeniedAsync(unset, $"Bearer {presented}");
foreach (string message in configuredLog.Entries.Concat(unsetLog.Entries).Select(e => e.Message))
{
Assert.DoesNotContain(ConfiguredToken, message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(presented, message, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void Interceptor_has_exactly_one_public_constructor()
{
// Grpc.AspNetCore's interceptor activator silently stops invoking an interceptor whose
// constructor is ambiguous — the failure mode is an UNAUTHENTICATED gate that quietly
// stops running, so this is pinned rather than left to review.
ConstructorInfo[] ctors = typeof(SecretsHubAuthInterceptor)
.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
Assert.Single(ctors);
}
private static SecretsHubAuthInterceptor Build(string token, out HubCapturingLogger<SecretsHubAuthInterceptor> logger)
{
logger = new HubCapturingLogger<SecretsHubAuthInterceptor>();
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
return new SecretsHubAuthInterceptor(Options.Create(options), logger);
}
private static async Task<RpcException> AssertDeniedAsync(
SecretsHubAuthInterceptor interceptor, string? header)
{
RpcException error = await Assert.ThrowsAsync<RpcException>(
() => interceptor.UnaryServerHandler(
new GetManifestRequest(),
HubServerCallContextStub.WithAuthorization(header),
(_, _) => Task.FromResult(new GetManifestResponse())));
Assert.Equal(StatusCode.Unauthenticated, error.StatusCode);
return error;
}
}
/// <summary>
/// A minimal <see cref="ServerCallContext"/> for exercising interceptors and service methods
/// directly. Hand-rolled because <c>Grpc.Core.Testing.TestServerCallContext</c> ships in a
/// package this project deliberately does not reference.
/// </summary>
internal sealed class HubServerCallContextStub : ServerCallContext
{
private readonly Metadata _requestHeaders;
private readonly Metadata _responseTrailers = new();
private readonly AuthContext _authContext = new(null, new Dictionary<string, List<AuthProperty>>());
private readonly Dictionary<object, object> _userState = new();
private HubServerCallContextStub(Metadata requestHeaders) => _requestHeaders = requestHeaders;
/// <summary>Builds a context carrying the given <c>Authorization</c> header, or none when null.</summary>
public static HubServerCallContextStub WithAuthorization(string? header)
{
var headers = new Metadata();
if (header is not null)
{
headers.Add("Authorization", header);
}
return new HubServerCallContextStub(headers);
}
/// <summary>Builds a context with no metadata at all, for service-level tests.</summary>
public static HubServerCallContextStub Empty() => new(new Metadata());
protected override string MethodCore => "/zb.mom.ww.secrets.hub.v1.SecretsHub/Test";
protected override string HostCore => "localhost";
protected override string PeerCore => "ipv4:127.0.0.1:0";
protected override DateTime DeadlineCore => DateTime.UtcNow.AddSeconds(30);
protected override Metadata RequestHeadersCore => _requestHeaders;
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
protected override Metadata ResponseTrailersCore => _responseTrailers;
protected override Status StatusCore { get; set; }
protected override WriteOptions? WriteOptionsCore { get; set; }
protected override AuthContext AuthContextCore => _authContext;
protected override IDictionary<object, object> UserStateCore => _userState;
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
throw new NotSupportedException("Context propagation is not used by the secrets hub.");
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
}
/// <summary>A no-op response stream, enough to drive the server-streaming interceptor path.</summary>
internal sealed class HubServerStreamWriterStub<T> : IServerStreamWriter<T>
{
/// <summary>Everything written, in order.</summary>
public List<T> Written { get; } = [];
/// <inheritdoc/>
public WriteOptions? WriteOptions { get; set; }
/// <inheritdoc/>
public Task WriteAsync(T message)
{
Written.Add(message);
return Task.CompletedTask;
}
}
/// <summary>Captures formatted log messages so tests can assert on what was — and was not — logged.</summary>
internal sealed class HubCapturingLogger<TCategory> : ILogger<TCategory>
{
/// <summary>Every entry logged, formatted exactly as a sink would render it.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = [];
/// <inheritdoc/>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc/>
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc/>
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
ArgumentNullException.ThrowIfNull(formatter);
Entries.Add((logLevel, formatter(state, exception)));
}
}
@@ -0,0 +1,322 @@
using System.Globalization;
using Google.Protobuf;
using Microsoft.Extensions.Logging.Abstractions;
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.Tests;
/// <summary>
/// Covers the read-only hub service and the domain→wire projection behind it. The contract the
/// proto states is binding here: a name the hub does not hold is omitted, never an error, and
/// every <see cref="StoredSecret"/> field crosses losslessly.
/// </summary>
public sealed class SecretsHubServiceTests
{
[Fact]
public async Task GetManifest_returns_every_entry_including_tombstones()
{
var store = new HubFakeSecretStore();
store.Seed(Row("alpha"));
store.Seed(Row("beta", isDeleted: true));
SecretsHubService service = Service(store);
GetManifestResponse response =
await service.GetManifest(new GetManifestRequest(), HubServerCallContextStub.Empty());
Assert.Equal(["alpha", "beta"], response.Entries.Select(e => e.Name).Order());
ManifestEntry tombstone = response.Entries.Single(e => e.Name == "beta");
Assert.True(tombstone.IsDeleted);
}
[Fact]
public async Task GetManifest_maps_every_manifest_field()
{
var updated = new DateTimeOffset(2026, 8, 7, 13, 45, 12, 345, TimeSpan.FromHours(-5))
.AddTicks(6789);
var store = new HubFakeSecretStore();
store.SeedManifest(new SecretManifestEntry
{
Name = new SecretName("db/central"),
Revision = 42,
UpdatedUtc = updated,
IsDeleted = true,
});
GetManifestResponse response = await Service(store)
.GetManifest(new GetManifestRequest(), HubServerCallContextStub.Empty());
ManifestEntry entry = Assert.Single(response.Entries);
Assert.Equal("db/central", entry.Name);
Assert.Equal(42, entry.Revision);
Assert.Equal(updated.ToString("O", CultureInfo.InvariantCulture), entry.UpdatedUtc);
Assert.True(entry.IsDeleted);
}
[Fact]
public async Task GetSecrets_omits_names_the_hub_does_not_hold()
{
var store = new HubFakeSecretStore();
store.Seed(Row("present"));
var request = new GetSecretsRequest { Names = { "present", "absent" } };
GetSecretsResponse response =
await Service(store).GetSecrets(request, HubServerCallContextStub.Empty());
SecretRow row = Assert.Single(response.Secrets);
Assert.Equal("present", row.Name);
}
[Fact]
public async Task GetSecrets_returns_tombstoned_rows()
{
// A follower needs the tombstone itself to converge on a delete — omitting it would make a
// deleted secret un-deletable downstream.
var store = new HubFakeSecretStore();
store.Seed(Row("gone", isDeleted: true));
GetSecretsResponse response = await Service(store)
.GetSecrets(new GetSecretsRequest { Names = { "gone" } }, HubServerCallContextStub.Empty());
Assert.True(Assert.Single(response.Secrets).IsDeleted);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("../etc/passwd")]
[InlineData("/rooted")]
[InlineData("has space")]
[InlineData("double//slash")]
public async Task GetSecrets_skips_an_invalid_name_instead_of_faulting(string invalid)
{
var store = new HubFakeSecretStore();
store.Seed(Row("present"));
var request = new GetSecretsRequest { Names = { invalid, "present" } };
GetSecretsResponse response =
await Service(store).GetSecrets(request, HubServerCallContextStub.Empty());
Assert.Equal("present", Assert.Single(response.Secrets).Name);
}
[Fact]
public async Task GetSecrets_normalizes_names_before_looking_them_up()
{
var store = new HubFakeSecretStore();
store.Seed(Row("db/central"));
GetSecretsResponse response = await Service(store).GetSecrets(
new GetSecretsRequest { Names = { " DB/Central " } }, HubServerCallContextStub.Empty());
Assert.Equal("db/central", Assert.Single(response.Secrets).Name);
}
[Fact]
public async Task GetSecrets_returns_one_row_per_distinct_name()
{
var store = new HubFakeSecretStore();
store.Seed(Row("dup"));
GetSecretsResponse response = await Service(store).GetSecrets(
new GetSecretsRequest { Names = { "dup", "dup", "DUP" } }, HubServerCallContextStub.Empty());
Assert.Single(response.Secrets);
Assert.Equal(1, store.GetCallCount);
}
[Fact]
public async Task GetSecrets_on_an_empty_request_reads_nothing()
{
var store = new HubFakeSecretStore();
store.Seed(Row("present"));
GetSecretsResponse response = await Service(store)
.GetSecrets(new GetSecretsRequest(), HubServerCallContextStub.Empty());
Assert.Empty(response.Secrets);
Assert.Equal(0, store.GetCallCount);
}
[Fact]
public void DomainToWire_maps_a_fully_populated_row_field_by_field()
{
// Deliberately non-UTC offsets: DateTimeOffset carries an offset that a UTC-instant
// representation would silently normalize away.
var created = new DateTimeOffset(2026, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(-5)).AddTicks(9012);
var updated = new DateTimeOffset(2026, 8, 7, 9, 10, 11, 121, TimeSpan.FromHours(5.5)).AddTicks(3456);
var deleted = new DateTimeOffset(2026, 8, 7, 9, 30, 0, TimeSpan.FromHours(9));
var row = new StoredSecret
{
Name = new SecretName("db/central"),
Description = "central connection string",
ContentType = SecretContentType.ConnectionString,
Ciphertext = [1, 2, 3, 4],
Nonce = [5, 6, 7],
Tag = [8, 9],
WrappedDek = [10, 11, 12],
WrapNonce = [13, 14],
WrapTag = [15],
KekId = "kek-2026-08",
Revision = 7,
IsDeleted = true,
DeletedUtc = deleted,
CreatedUtc = created,
UpdatedUtc = updated,
CreatedBy = "alice",
UpdatedBy = "bob",
};
SecretRow wire = DomainToWire.ToWire(row);
Assert.Equal("db/central", wire.Name);
Assert.Equal("central connection string", wire.Description);
Assert.Equal("ConnectionString", wire.ContentType);
Assert.Equal(new byte[] { 1, 2, 3, 4 }, wire.Ciphertext.ToByteArray());
Assert.Equal(new byte[] { 5, 6, 7 }, wire.Nonce.ToByteArray());
Assert.Equal(new byte[] { 8, 9 }, wire.Tag.ToByteArray());
Assert.Equal(new byte[] { 10, 11, 12 }, wire.WrappedDek.ToByteArray());
Assert.Equal(new byte[] { 13, 14 }, wire.WrapNonce.ToByteArray());
Assert.Equal(new byte[] { 15 }, wire.WrapTag.ToByteArray());
Assert.Equal("kek-2026-08", wire.KekId);
Assert.Equal(7, wire.Revision);
Assert.True(wire.IsDeleted);
Assert.Equal(deleted.ToString("O", CultureInfo.InvariantCulture), wire.DeletedUtc);
Assert.Equal(created.ToString("O", CultureInfo.InvariantCulture), wire.CreatedUtc);
Assert.Equal(updated.ToString("O", CultureInfo.InvariantCulture), wire.UpdatedUtc);
Assert.Equal("alice", wire.CreatedBy);
Assert.Equal("bob", wire.UpdatedBy);
}
[Fact]
public void DomainToWire_leaves_nullable_fields_absent_rather_than_empty()
{
StoredSecret row = Row("minimal");
SecretRow wire = DomainToWire.ToWire(row);
Assert.False(wire.HasDescription);
Assert.False(wire.HasDeletedUtc);
Assert.False(wire.HasCreatedBy);
Assert.False(wire.HasUpdatedBy);
}
[Fact]
public void DomainToWire_distinguishes_an_empty_string_from_a_null()
{
StoredSecret row = Row("empties") with { Description = string.Empty, CreatedBy = string.Empty };
SecretRow wire = DomainToWire.ToWire(row);
Assert.True(wire.HasDescription);
Assert.Equal(string.Empty, wire.Description);
Assert.True(wire.HasCreatedBy);
Assert.Equal(string.Empty, wire.CreatedBy);
}
[Theory]
[InlineData(SecretContentType.Text, "Text")]
[InlineData(SecretContentType.ConnectionString, "ConnectionString")]
[InlineData(SecretContentType.Json, "Json")]
[InlineData(SecretContentType.BinaryBase64, "BinaryBase64")]
public void DomainToWire_writes_the_content_type_member_name(SecretContentType type, string expected)
{
SecretRow wire = DomainToWire.ToWire(Row("typed") with { ContentType = type });
Assert.Equal(expected, wire.ContentType);
}
private static SecretsHubService Service(ISecretStore store) =>
new(store, NullLogger<SecretsHubService>.Instance);
private static StoredSecret Row(string name, bool isDeleted = false) => new()
{
Name = new SecretName(name),
ContentType = SecretContentType.Text,
Ciphertext = [1],
Nonce = [2],
Tag = [3],
WrappedDek = [4],
WrapNonce = [5],
WrapTag = [6],
KekId = "kek",
Revision = 1,
IsDeleted = isDeleted,
DeletedUtc = isDeleted ? DateTimeOffset.UnixEpoch : null,
CreatedUtc = DateTimeOffset.UnixEpoch,
UpdatedUtc = DateTimeOffset.UnixEpoch,
};
}
/// <summary>
/// An in-memory <see cref="ISecretStore"/>. Only the two read members the hub adapts are
/// implemented; the write half throws, which is itself part of the pull-only pin — a hub that ever
/// started writing would fail loudly in these tests.
/// </summary>
internal sealed class HubFakeSecretStore : ISecretStore
{
private readonly Dictionary<string, StoredSecret> _rows = new(StringComparer.Ordinal);
private readonly List<SecretManifestEntry> _manifestOverride = [];
/// <summary>How many single-row reads the service issued.</summary>
public int GetCallCount { get; private set; }
/// <summary>Adds a row, and the manifest entry derived from it.</summary>
public void Seed(StoredSecret row) => _rows[row.Name.Value] = row;
/// <summary>Adds a manifest entry with no backing row, for manifest-shape assertions.</summary>
public void SeedManifest(SecretManifestEntry entry) => _manifestOverride.Add(entry);
/// <inheritdoc/>
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
{
GetCallCount++;
return Task.FromResult(_rows.GetValueOrDefault(name.Value));
}
/// <inheritdoc/>
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
{
if (_manifestOverride.Count > 0)
{
return Task.FromResult<IReadOnlyList<SecretManifestEntry>>(_manifestOverride);
}
IReadOnlyList<SecretManifestEntry> entries = _rows.Values
.Select(r => new SecretManifestEntry
{
Name = r.Name,
Revision = r.Revision,
UpdatedUtc = r.UpdatedUtc,
IsDeleted = r.IsDeleted,
})
.ToList();
return Task.FromResult(entries);
}
/// <inheritdoc/>
public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc/>
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc/>
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc/>
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException();
/// <inheritdoc/>
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
throw new NotSupportedException();
}