feat(auth.apikeys.sqlserver): package skeleton, options (schema default zbauth, validated) and connection factory

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
Joseph Doherty
2026-09-02 14:08:22 -04:00
parent 3f11704d10
commit 2969805e4e
8 changed files with 201 additions and 0 deletions
@@ -0,0 +1,64 @@
using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
/// <summary>
/// Opens connections to the shared SQL Server API-key database and carries the settings every
/// command needs (schema name, command timeout).
/// </summary>
/// <param name="options">The SQL Server API-key store options; validated in the constructor.</param>
/// <exception cref="InvalidOperationException"><paramref name="options"/> is not usable.</exception>
public sealed class AuthSqlServerConnectionFactory(SqlServerApiKeyOptions options)
{
private readonly SqlServerApiKeyOptions _options = Validate(options);
private static SqlServerApiKeyOptions Validate(SqlServerApiKeyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
return options;
}
/// <summary>The schema that owns the three API-key tables.</summary>
public string Schema => _options.Schema;
/// <summary>Command timeout, in whole seconds, for every command issued against this store.</summary>
public int CommandTimeoutSeconds => Math.Max(1, (int)_options.CommandTimeout.TotalSeconds);
/// <summary>Opens a connection to the shared API-key database.</summary>
/// <param name="cancellationToken">A token to cancel the connect.</param>
/// <returns>An opened connection. The caller owns disposal.</returns>
public async Task<SqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connection = new SqlConnection(_options.ConnectionString);
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Creates a command on <paramref name="connection"/> with the configured timeout applied.
/// </summary>
/// <param name="connection">The open connection.</param>
/// <param name="commandText">The command text.</param>
/// <param name="transaction">The ambient transaction, if any.</param>
/// <returns>The configured command. The caller owns disposal.</returns>
public SqlCommand CreateCommand(
SqlConnection connection, string commandText, SqlTransaction? transaction = null)
{
ArgumentNullException.ThrowIfNull(connection);
SqlCommand command = connection.CreateCommand();
command.CommandText = commandText;
command.CommandTimeout = CommandTimeoutSeconds;
command.Transaction = transaction;
return command;
}
}
@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
/// <summary>
/// Options for the shared SQL Server API-key store (section supplied by the host, e.g.
/// <c>ScadaBridge:InboundApi:ApiKeyStore:SqlServer</c>).
/// </summary>
public sealed class SqlServerApiKeyOptions
{
/// <summary>
/// Full SQL Server connection string. Required. Never a <c>${secret:}</c> reference on hosts
/// whose secret store is itself SQL-backed.
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>Schema that owns the three tables. Letters, digits and underscore only (it is interpolated into DDL).</summary>
public string Schema { get; set; } = "zbauth";
/// <summary>
/// Command timeout applied to every store operation. Defaults to 30 seconds — long enough for
/// a contended shared database, short enough that a hung server surfaces as an error rather
/// than stalling a resolve indefinitely.
/// </summary>
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Fails fast on a blank connection string, an unsafe schema identifier, or a non-positive command timeout.</summary>
/// <exception cref="InvalidOperationException">The options are not usable.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString))
throw new InvalidOperationException("SQL Server API-key store requires a ConnectionString.");
if (string.IsNullOrEmpty(Schema) || !Schema.All(c => char.IsAsciiLetterOrDigit(c) || c == '_') || Schema.Length > 64)
throw new InvalidOperationException($"SQL Server API-key store schema name '{Schema}' is not a safe identifier.");
if (CommandTimeout <= TimeSpan.Zero)
throw new InvalidOperationException($"SQL Server API-key store CommandTimeout must be positive (was {CommandTimeout}).");
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.Auth.ApiKeys\ZB.MOM.WW.Auth.ApiKeys.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<!--
Lightweight Microsoft.Extensions.* abstractions back the DI extension
(AddZbApiKeyAuthSqlServer, shipped by Task 6): IServiceCollection / TryAdd* / Configure
(DependencyInjection.Abstractions), IOptions (Options), IConfiguration
(Configuration.Abstractions), and IHostedService / AddHostedService (Hosting.Abstractions).
-->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<!-- Supplies the Configure<TOptions>(IConfiguration) binding overload used by the DI extension. -->
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Auth.ApiKeys.SqlServer</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>SQL Server API-key store for the ZB.MOM.WW SCADA family — one shared row set for clustered hosts (central pairs).</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests" />
</ItemGroup>
</Project>
@@ -38,6 +38,9 @@
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.ApiKeys.Tests" />
<!-- Task 5's SqliteApiKeyStoreImporter parses SQLite values with SqliteValueParsing (internal)
rather than duplicating the parser. -->
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer" />
</ItemGroup>
</Project>